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.

CounterComponent
count:0

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.

Parent
CounterComponent
count:0

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>
  );
};
Parent
count:0
Child
Grandchild

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>
);
Parent
CounterComponent
count:0
Child
Grandchild

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:

Parent
ContextProvider
count:0
Child
CountDisplay
count: 0
Child
CountButton

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 />;
});
Parent
Child (memo)
Grandchild

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.

Parent
Child — Primitive
Primitive:Hello
Child — Object
Object:{"name":"Kermit"}
Child — Memoised object
Memoised object:{"name":"Gonzo"}

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.

Parent
Child — Raw function
Raw function:fn()
Child — Memoised function
Memoised function:fn()
Child — useCallback
useCallback:fn()

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 changed
  • useMemo — stabilise object references between renders
  • useCallback — 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

  1. What is JSX — Kent C. Dodds
  2. A deep dive into React’s reconciliation algorithm
  3. Reconciliation in React
  4. Optimize React re-renders — Kent C. Dodds
  5. Equality comparisons in JavaScript
  6. useMemo and useCallback — Josh W. Comeau