Make a component optionally controlled or uncontrolled - reactjs

So I am trying to create this Collapse component either controlled or uncontrolled, optionally, depending on how its used, but that is quickly becoming a headache.
export const Collapse = ({
ExpandIcon = StyledExpandIcon,
CollapseIcon = StyledCollapseIcon,
expanded = false,
noButton = false,
...props
}) => {
const [isExpanded, setIsExpanded] = useState(expanded);
const handleSetExpanded = () => {
setIsExpanded((prevState) => !prevState);
};
return (
<>
{!noButton && (
<StyledIconButton onClick={handleSetExpanded}>
{isExpanded ? <CollapseIcon /> : <ExpandIcon />}
</StyledIconButton>
)}
<StyledCollapse in={expanded} {...props} />
</>
);
};
So I can't figure out how to make the useState optional, if the component is controlled, then its expanded state should be set somewhere outside of it, at the same time, both expanded and noButton should become required props, should either of them be supplied, so if I have suppplied only expanded then noButton should also be required, so that I remove the "default" button, same goes for first noButton.
I am pretty sure I can come up with some weird messed up logic and make it work, but what is the general way of doing something like this?

Your problem is your component being able to handle both controlled & uncontrolled values, right? This might not be on point to what use-case you might want. But in this case,
Add a variable to validate if your component is controlled/uncontrolled. You can check with const isControlled = expanded == null;
add an if statement to your handleSetExpanded to only call your setIsExpanded only if isControlled is false.
For your StyledCollapse && the inside of your StyledIconButton , you can pass a state of expanded || isExpanded. In that case, if expanded is null/undefined, then we'd be basing our value off of the state of your Collapse instead of the prop. Or better yet, can be isControlled ? expanded : isExpanded
Add warnings to your component (if not on prod)
This is what the code should look like.
export const Collapse = ({
ExpandIcon = StyledExpandIcon,
CollapseIcon = StyledCollapseIcon,
expanded = false,
noButton = false,
...props
}) => {
const [isExpanded, setIsExpanded] = useState(expanded)
const isControlled = expanded == null
const handleSetExpanded = () => {
if (!isControlled) {
setIsExpanded(prevState => !prevState)
}
}
if (isControlled && !noButton && process.env.NODE_ENV === 'production') {
React.useEffect(() => {
console.warning(
`You cannot use "expanded" prop without the "noButton" prop for the Collapse`,
)
}, [isControlled, noButton])
}
return (
<>
{!noButton && (
<StyledIconButton onClick={handleSetExpanded}>
{expanded || isExpanded ? <CollapseIcon /> : <ExpandIcon />}
</StyledIconButton>
)}
<StyledCollapse in={expanded || isExpanded} {...props} />
</>
)
}
Hope the code helps you or the bullet points at least guide you to what you want your code to look like! (Might I also point you to a Kent C Dodds talk that might guide you in the future)

Related

Prevent useContext value from updating every instance of a mapped component

I have a long list of components which takes a value from my Context.
Component has a unique id, and also knows which is the "current" id. I only want the Component whose id matches the currentId to update when the value from myContext changes.
const ComponentList = () => {
return(
<>
{data.map((d,i) =>
<Component key={i} id={d.id} />
}
</>
)
}
const Component = ({id}) => {
const {value} = useContext(MyContext);
const {currentId} = useContext(OtherContext);
//only re-render if `id === currentId` and `value` changes
return (
<h1>{value}</h1>
)
}
Note that the value from myContext does not care about which Component id is the current id.
I figure the answer lies somewhere in memoization but I haven't been successful in finding it. (I was able to get this working as I intend by doing const {value} = id === currentId ? useContext(MyContext) : {value: null} but this is obviously not okay)
I've abstracted this to a very generic example but if more context (haha) is necessary I can explain my use case further.
Seems like React.memo was the answer and it was fairly simple; just didn't see it at first.
I wrapped the Component in a memo and added my own condition.
const ParentComponent = (props) => {
const {value} = useContext(MyContext);
const {currentId} = useContext(OtherContext);
return (
<Component id={props.id} value={value} currentId={currentId} />
)
}
const Component = memo(
({value}) => {
return (
<h1>{value}</h1>
)
},
(prev, next) =>
prev.value === next.value || prev.id !== next.currentId
);
This essentially will not update if the previous and next value are equal, or if the id does not match the currentId.

React Hooks "useState/useEffect/useCallback" are called conditionally

Please tell me where do I need to put the list.length condition to remove the React Hooks are called conditionally error? I tried to wrap it in useEffect, but in this case an empty list is returned at the first render. It is important that the list is returned at the first render in the same way as with the logic in the code below.
const List = ({ list }) => {
if (list.length === 0) {
return <div>LOADING...</div>;
}
const [localList, setLocalList] = useState(list);
useEffect(() => {
setList(localList);
}, [localList]);
const handleChange = useCallback((id) => {
setLocalList((prevLocalList) =>
prevLocalList.map((item, index) => {
return index !== id ? item : { ...item, checked: !item.checked };
})
);
}, []);
return (
<>
{localList?.map((item, index) => (
<MemoRow key={index} {...item} handleChange={handleChange} />
))}
</>
);
};
The rendered result is returned at the end of the component, not at the beginning. Make that first operation part of the overall return at the end:
return (
list.length === 0 ?
<div>LOADING...</div> :
<>
{localList?.map((item, index) => (
<MemoRow key={index} {...item} handleChange={handleChange} />
))}
</>
);
Additionally, there is a logical issue in your component. When a parent component passes the list value, you are duplicating that in local state in this component. If the parent component changes the value of list, this component will re-render but will not update its local state.
Given the term "LOADING..." in the UI, this implies that's exactly what's happening here. So on a re-render, list.length === 0 is now false, but localList is still empty.
As a "quick fix" you can just update localList any time list changes:
useEffect(() => {
setLocalList(list);
}, [list, setLocalList]);
Of course, this will also over-write any local changes to localList if the parent component ever changes list again. But since this is duplicated state then it's not really clear what should happen in that case anyway. Perhaps you could only conditionally update it if localList is empty:
useEffect(() => {
if (localList.length === 0) {
setLocalList(list);
}
}, [list, setLocalList, localList]);
It's really up to you how you want to handle edge cases like that. But ultimately you're going to need to update localList after list has changed if you want those changes to be reflected in your local state.

I want only one component state to be true between multiple components

I am calling components as folloews
{userAddresses.map((useraddress, index) => {
return (
<div key={index}>
<Address useraddress={useraddress} />
</div>
);
})}
Their state:
const [showEditAddress, setShowEditAddress] = useState(false);
and this is how I am handling their states
const switchEditAddress = () => {
if (showEditAddress === false) {
setShowEditAddress(true);
} else {
setShowEditAddress(false);
}
};
Well, it's better if you want to toggle between true and false to use the state inside useEffect hook in react.
useEffect will render the component every time and will get into your condition to set the state true or false.
In your case, you can try the following:
useEffect(() => { if (showEditAddress === false) {
setShowEditAddress(true);
} else {
setShowEditAddress(false);
} }, [showEditAddress])
By using useEffect you will be able to reset the boolean as your condition.
Also find the link below to react more about useEffect.
https://reactjs.org/docs/hooks-effect.html
It would be best in my opinion to keep your point of truth in the parent component and you need to figure out what the point of truth should be. If you only want one component to be editing at a time then I would just identify the address you want to edit in the parent component and go from there. It would be best if you gave each address a unique id but you can use the index as well. You could do something like the following:
UserAddress Component
const UserAddress = ({index, editIndex, setEditIndex, userAddress}) => {
return(
<div>
{userAddress}
<button onClick={() => setEditIndex(index)}>Edit</button>
{editIndex === index && <div style={{color: 'green'}}>Your editing {userAddress}</div>}
</div>
)
}
Parent Component
const UserAddresses = () => {
const addresses = ['120 n 10th st', '650 s 41 st', '4456 Birch ave']
const [editIndex, setEditIndex] = useState(null)
return userAddresses.map((userAddress, index) => <UserAddress key={index} index={index} editIndex={editIndex} setEditIndex={setEditIndex} userAddress={userAddress}/>;
}
Since you didn't post the actual components I can only give you example components but this should give you an idea of how to achieve what you want.

ReactJS how to memoize within a loop to render the same component

I have a component that creates several components using a loop, but I need to rerender only the instance being modified, not the rest. This is my approach:
function renderName(item) {
return (
<TextField value={item.value || ''} onChange={edit(item.id)} />
);
}
function renderAllNames(items) {
const renderedItems = [];
items.forEach(x => {
const item = React.useMemo(() => renderName(x), [x]);
renderedItems.push(item);
});
return renderedItems;
};
return (
<>
{'Items'}
{renderAllNames(names)};
</>
);
This yells me that there are more hooks calls than in the previous render. Tried this instead:
function renderAllNames(items) {
const renderedItems = [];
items.forEach(x => {
const item = React.memo(renderName(x), (prev, next) => (prev.x === next.x));
renderedItems.push(item);
});
return renderedItems;
};
Didn't work either... the basic approach works fine
function renderAllNames(items) {
const renderedItems = [];
items.forEach(x => {
renderedItems.push(renderName(x));
});
return renderedItems;
};
But it renders all the dynamic component everytime I edit any of the fields, so how can I get this memoized in order to rerender only the item being edited?
You're breaking the rules of hooks. Hooks should only be used in the top level of a component so that React can guarantee call order. Component memoisation should also really only be done using React.memo, and components should only be declared in the global scope, not inside other components.
We could turn renderName into its own component, RenderName:
function RenderName({item, edit}) {
return (
<TextField value={item.value || ''} onChange={() => edit(item.id)} />
);
}
And memoise it like this:
const MemoRenderName = React.memo(RenderName, (prev, next) => {
const idEqual = prev.item.id === next.item.id;
const valEqual = prev.item.value === next.item.value;
const editEqual = prev.edit === next.edit;
return idEqual && valEqual && editEqual;
});
React.memo performs strict comparison on all the props by default. Since item is an object and no two objects are strictly equal, the properties must be deeply compared. A side note: this is only going to work if edit is a referentially stable function. You haven't shown it but it would have to be wrapped in a memoisation hook of its own such as useCallback or lifted out of the render cycle entirely.
Now back in the parent component you can map names directly:
return (
<>
{'Items'}
{names.map(name => <MemoRenderName item={name} edit={edit}/>)}
</>
);

How to target a specific item to toggleClick on using React Hooks?

I have a navbar component with that actual info being pulled in from a CMS. Some of the nav links have a dropdown component onclick, while others do not. I'm having a hard time figuring out how to target a specific menus index with React Hooks - currently onClick, it opens ALL the dropdown menus at once instead of the specific one I clicked on.
The prop toggleOpen is being passed down to a styled component based on the handleDropDownClick event handler.
Heres my component.
const NavBar = props => {
const [links, setLinks] = useState(null);
const [notFound, setNotFound] = useState(false);
const [isOpen, setIsOpen] = useState(false);
const fetchLinks = () => {
if (props.prismicCtx) {
// We are using the function to get a document by its uid
const data = props.prismicCtx.api.query([
Prismic.Predicates.at('document.tags', [`${config.source}`]),
Prismic.Predicates.at('document.type', 'navbar'),
]);
data.then(res => {
const navlinks = res.results[0].data.nav;
setLinks(navlinks);
});
}
return null;
};
const checkForLinks = () => {
if (props.prismicCtx) {
fetchLinks(props);
} else {
setNotFound(true);
}
};
useEffect(() => {
checkForLinks();
});
const handleDropdownClick = e => {
e.preventDefault();
setIsOpen(!isOpen);
};
if (links) {
const linkname = links.map(item => {
// Check to see if NavItem contains Dropdown Children
return item.items.length > 1 ? (
<Fragment>
<StyledNavBar.NavLink onClick={handleDropdownClick} href={item.primary.link.url}>
{item.primary.label[0].text}
</StyledNavBar.NavLink>
<Dropdown toggleOpen={isOpen}>
{item.items.map(subitem => {
return (
<StyledNavBar.NavLink href={subitem.sub_nav_link.url}>
<span>{subitem.sub_nav_link_label[0].text}</span>
</StyledNavBar.NavLink>
);
})}
</Dropdown>
</Fragment>
) : (
<StyledNavBar.NavLink href={item.primary.link.url}>
{item.primary.label[0].text}
</StyledNavBar.NavLink>
);
});
// Render
return (
<StyledNavBar>
<StyledNavBar.NavContainer wide>
<StyledNavBar.NavWrapper row center>
<Logo />
{linkname}
</StyledNavBar.NavWrapper>
</StyledNavBar.NavContainer>
</StyledNavBar>
);
}
if (notFound) {
return <NotFound />;
}
return <h2>Loading Nav</h2>;
};
export default NavBar;
Your problem is that your state only handles a boolean (is open or not), but you actually need multiple booleans (one "is open or not" for each menu item). You could try something like this:
const [isOpen, setIsOpen] = useState({});
const handleDropdownClick = e => {
e.preventDefault();
const currentID = e.currentTarget.id;
const newIsOpenState = isOpen[id] = !isOpen[id];
setIsOpen(newIsOpenState);
};
And finally in your HTML:
const linkname = links.map((item, index) => {
// Check to see if NavItem contains Dropdown Children
return item.items.length > 1 ? (
<Fragment>
<StyledNavBar.NavLink id={index} onClick={handleDropdownClick} href={item.primary.link.url}>
{item.primary.label[0].text}
</StyledNavBar.NavLink>
<Dropdown toggleOpen={isOpen[index]}>
// ... rest of your component
Note the new index variable in the .map function, which is used to identify which menu item you are clicking.
UPDATE:
One point that I was missing was the initialization, as mention in the other answer by #MattYao. Inside your load data, do this:
data.then(res => {
const navlinks = res.results[0].data.nav;
setLinks(navlinks);
setIsOpen(navlinks.map((link, index) => {index: false}));
});
Not related to your question, but you may want to consider skipping effects and including a key to your .map
I can see the first two useState hooks are working as expected. The problem is your 3rd useState() hook.
The issue is pretty obvious that you are referring the same state variable isOpen by a list of elements so they all have the same state. To fix the problems, I suggest the following way:
Instead of having one value of isOpen, you will need to initialise the state with an array or Map so you can refer each individual one:
const initialOpenState = [] // or using ES6 Map - new Map([]);
In your fetchLink function callback, initialise your isOpen state array values to be false. So you can put it here:
data.then(res => {
const navlinks = res.results[0].data.nav;
setLinks(navlinks);
// init your isOpen state here
navlinks.forEach(link => isOpen.push({ linkId: link.id, value: false })) //I suppose you can get an id or similar identifers
});
In your handleClick function, you have to target the link object and set it to true, instead of setting everything to true. You might need to use .find() to locate the link you are clicking:
handleClick = e => {
const currentOpenState = state;
const clickedLink = e.target.value // use your own identifier
currentOpenState[clickedLink].value = !currentOpenState[clickedLink].value;
setIsOpen(currentOpenState);
}
Update your component so the correct isOpen state is used:
<Dropdown toggleOpen={isOpen[item].value}> // replace this value
{item.items.map(subitem => {
return (
<StyledNavBar.NavLink href={subitem.sub_nav_link.url}>
<span>{subitem.sub_nav_link_label[0].text}</span>
</StyledNavBar.NavLink>
);
})}
</Dropdown>
The above code may not work for you if you just copy & paste. But it should give you an idea how things should work together.

Resources