Rendering in React
React can make applications incredibly reactive, but with that comes a challenge — React is a glutton for recalculation and re-renders. Every time React detects a difference in props or state, it may re-render components, sometimes unnecessarily. Writing performant React code isn’t just about making things reactive; it’s about working with React to help it avoid its gluttonous impulse to re-render everything.
The demos below flash blue on mount and yellow on re-render.
The Basics
A single component with a single state variable. Press the button to increment the count. The component re-renders, and you’ll see it flash.
The count flashed because its value changed. That caused the whole CounterComponent to flash too — because it re-rendered.
Now, what happens when we add a child?
The Inevitable Complexity of Children
Here the parent is a static wrapper — it has no state of its own. The child holds all the state. Increment and watch what re-renders.
State changes in the child, the child re-renders — simple. But things get more complicated when the parent updates.
The Parent Trap
A pretty common pattern: nested components, each containing the next.
const Parent = () => {
const [count, setCount] = useState(0);
return (
<div>
<Child />
</div>
);
};
Everything re-renders. The parent updated, so React re-renders the whole tree below it.
You may have noticed the children flash after the parent. That’s a visual delay I added — React renders the tree top-down, calling each component function in order from parent to child. I think of it as: the parent sets the stage, then the children perform.
Reconciliation
What do you get from calling a React function? Eventually, an object — derived from the JSX your component returns. Reconciliation is React comparing each level of that object to what it was on the previous render.
The key insight is: React only re-renders a component if it thinks something relevant has changed. “Relevant” means a change in the component’s own state, its props, or the context it consumes. If we’re careful about how we structure the tree, we can avoid unnecessary re-renders without any extra tools.
If we extract the stateful part into its own component and pass the rest as children, something interesting happens:
const Counter = ({ children }) => {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Increment</button>
{children}
</div>
);
};
const Parent = () => (
<Counter>
<Child>
<Grandchild />
</Child>
</Counter>
);
The children don’t re-render. Counter updated — it owns the state — but Child and Grandchild were passed in from Parent, which didn’t re-render. React sees that their element descriptions haven’t changed, so it leaves them alone.
This is especially useful with React Context, which is typically defined high up in the tree. Here’s a Provider with two Consumers — one updates the value, one displays it:
The Child components don’t re-render. Only the components that consume context re-render when context changes.
Memoising Components
What if we know a component hasn’t changed, despite having a parent that has? We can wrap it in React.memo():
const MemoisedChild = React.memo(() => {
return <Child />;
});
The parent re-renders, but the memoised child doesn’t. React.memo() skips calling the function and returns its previous value — unless props change.
The Problem of Equality
If you have two objects with the same values, are they equal?
In JavaScript — no. Objects are compared by reference, not by value. That means a new object created on every render is never equal to the previous one, even if its contents are identical. And that breaks memoisation.
The component receiving a primitive ("Hello") doesn’t re-render — strings compare by value. The component receiving a plain object does re-render — it’s a new reference every time. The memoised object doesn’t re-render — useMemo keeps the reference stable.
Callbacks
Functions have the same problem. A function defined inline is a new reference on every render. useCallback solves this the same way useMemo solves it for values.
Conclusions
React is a little too reactive, and we have tools for reining it in:
- Structure — extract dynamic elements so reconciliation does the work for free
React.memo()— skip re-renders when props haven’t changeduseMemo— stabilise object references between rendersuseCallback— stabilise function references between renders
Some of you may be thinking: didn’t React announce a compiler that makes all this manual optimisation unnecessary? Yeah. There is that. It’s still interesting though, and it’s pretty easy to write.
References
- What is JSX — Kent C. Dodds
- A deep dive into React’s reconciliation algorithm
- Reconciliation in React
- Optimize React re-renders — Kent C. Dodds
- Equality comparisons in JavaScript
- useMemo and useCallback — Josh W. Comeau