Take a look at this codesandbox demo. We've got a list of items and we delete on of them when clicked. It works fine, but has one minor problem.
If we open React DevTools, go to Settings and enable "Highlight updates", this is what we are going to see.
Every time we delete one of the items the whole list is getting re-rendered. And it makes total sense, because that's how React works. When parent components' state changes React will recursively re-render all of its children. Unless we tell him no to.
How exactly? For class components we had shouldComponentUpdate
and PureComponent
, for function components we can use memo
. React will shallowly compare props and skip rendering if they stayed the same.
const Comment = memo(({ name, id, onClick }) => { return (
<li onClick={() => onClick(id)}>
{id}: {name}
</li>
);
});
And that's it, right? Nope, we are still re-rendering everything. Function components are full of surprises. And that's not exactly React fault, that's just how functions in JavaScript work.
Let's take a look at our App
component.
handleChange
executes App
function is called handleChange
variablehandleChange
gets passed to Comment
componentAnd that's where the memo
comparison chokes. We create new function on every render. But the thing is, even if two functions in JavaScript look exactly the same, they aren't. You can check it in your browser console.
const one = function() {}
const two = function() {}
one === two
// false
But no worries, React team has our back covered. useCallback
comes to the rescue.
useCallback will return a memoized version of the callback that only changes if one of the dependencies has changed. This is useful when passing callbacks to optimized child components that rely on reference equality to prevent unnecessary renders.
Alright, think we've got it. Just wrap our handleClick
function and don't specify any dependencies.
const handleClick = useCallback(id => {
setComments(comments.filter(c => c.id !== id));
}, []);
Aaand we've just created a stale closure, which again has nothing to do with React. Because we didn't include comments
in our dependency list this variable will always contain it's initial value []
inside the closure. So after click the whole list will simply disappear.
What's going to happen if we add the dependency?
const handleClick = useCallback(id => {
setComments(comments.filter(c => c.id !== id));
}, [comments]);
We are back to square one. Now our state updates, causes re-render and handleClick
gets recreated because comments
changed.
We need some way to access current comments
without specifying them in our dependency list. A for that we are going to use functional updates.
const handleClick = useCallback(id => {
setComments(comments => comments.filter(c => c.id !== id));}, []);
Something changed! Memoization seems to work.. but only partly. Everything below the deleted element somehow still gets re-rendered.
Let's take a look at our key
property. Right now we are using index provided by map
function. Every time we delete an element the index of keys below get shifted by 1. React recreates a component when its key changes. We need to switch to something unique, for example comment.id
.
{comments.map(comment => {
return <Comment {...comment} key={comment.id} onClick={handleClick} />;})}
Finally! Here is the codesandbox demo after all the changes.
I know what some of you might be thinking. "It was kinda simpler with class components", and you might be right. But on the other hand we can say that we now understand JavaScript a little better.
By the way, don't bother with long lists anyway. Just use react-window.