Here's a simple question - when do the children of a React component re-render? At first glance, the answer is obvious - React children will re-render when either their props/state change or their parent component re-renders. But, as usual, there's more to the story.
Let's explore parent component re-renders in more detail.
The usual scenario
Will the ChildComponent
re-render when we click the button in the parent component?
import { useState } from 'react';
const ChildComponent = () => {
console.log('I re-rendered');
return <p>Child</p>;
};
const ParentComponent = () => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(number + 1)}>{count}</button>
<Child />
</div>
);
};
export default ParentComponent;
It definitely will! The parent re-renders on every button click and so does the child. This how React works, after all.
Hmm! But ChildComponent
doesn't really need to re-render. What should we do about it?
The instinctive reaction - React.memo
The short answer is - in this case, absolutely nothing! We don't have any evidence that this is causing performance problems. Micro-optimising React components in similar scenarios is, in fact, likely to hurt performance and complicate our codebase unnecessarily.
Still, the temptation to solve for empty re-renders is always there. Or, maybe we have a different use case, where there's a legitimate need for memoization (more on that in a moment). So what do we usually do? Instinctively, we add memo
to our ChildComponent
. That way, we are sure it's not going to re-render unnecessarily:
import { memo } from 'React'
const ChildComponent = memo(() => {
console.log('I re-rendered');
return <p>Child</p>;
});
But here is a question - is there, perhaps, a better way?
A better way - children
as a prop
Let's re-structure our components a bit and see what happens.
const ChildComponent = () => {
console.log('I re-rendered');
return <p>Child</p>;
};
const ParentComponent = ({ children }) => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(number + 1)}>{number}</button>
{children}
</div>
);
};
const App = () => {
return (
<ParentComponent>
<ChildComponent />
</ParentComponent>
);
};
export default App;
The functionality of our app remains exactly the same as before. Yet, the ChildComponent
doesn't re-render anymore even though the state of the parent is updated when the button is clicked. And we didn't use any explicit memoization techniques to achieve this.
So what's the secret? Instead of nesting our ChildComponent
directly in the parent, we used children
as a prop and placed it in the App
instead. This technique is known as "lifting content up". That way, even when ParentComponent
re-renders, React can skip re-rendering the part of the component tree that relates to ChildComponent
. For a more in-dept explanation on how this works, check out this excellent article by Dan Abramov.
Amazing, right! But wait a minute...
We already established that, in most cases, memoizing the children is not even needed. So when is this technique even relevant?
React context
The above can be useful any time we have an expensive React sub-tree we don't want to re-render frequently.
Here's an example. Let's re-structure our App
yet again so that this time, our count
is kept in React context. It is not unusual for a React context provider to wrap our entire app. So, in this case, it's important to make sure we are not re-rendering every single component down the line when a context value changes.
import { useState, useContext, createContext } from "react";
const Context = createContext();
const ChildWithCount = () => {
const { count, setCount } = useContext(Context);
console.log("ChildWithCount re-renders");
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<p>Child</p>
</div>
);
};
const ExpensiveChild = () => {
console.log("ExpensiveChild re-renders");
return <p>Expensive child</p>;
};
const CountContext = () => {
const [count, setCount] = useState(0);
const contextValue = { count, setCount };
return (
<Context.Provider value={contextValue}>
<ChildWithCount />
<ExpensiveChild /> // Imagine re-rendering this component is expensive
</Context.Provider>
);
};
const App = () => {
return <CountContext />;
};
export default App;
This time, we created two child components - one that consumes the CountContext
we introduced and one that does not. So what happens when I click the button?
With the structure above, both child components will re-render on every button click. And while this is expected behaviour for the component that is consuming CountContext
, it's not something we want to happen for our ExpensiveChild
component. Depending on the app structure and how often these re-renders occur, this can be the cause for legitimate performance issues. So let's fix it!
Instead of adding complexity to our app by manually memoizing components, we can just use children
as a prop to solve the problem.
import { useState, useContext, createContext } from "react";
const Context = createContext();
const ChildWithCount = () => {
const { count, setCount } = useContext(Context);
console.log("ChildWithCount re-renders");
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<p>Child</p>
</div>
);
};
const ExpensiveChild = () => {
console.log("ExpensiveChild re-renders");
return <p>Expensive child</p>;
};
const CountContext = ({ children }) => {
const [count, setCount] = useState(0);
const contextValue = { count, setCount };
return <Context.Provider value={contextValue}>{children}</Context.Provider>;
};
const App = () => {
return (
<CountContext>
<ChildWithCount />
<ExpensiveChild />
</CountContext>
);
};
export default App;
Everything works as expected in our app now - both in terms of functionality and performance. And with no cost to code readability or maintainability.
All we needed was to understand when React children re-render.
Happy coding! ๐ซ