React  components - when do children re-render?

React components - when do children re-render?

Iva Kop's photo
Iva Kop
ยทOct 23, 2022ยท

5 min read

Play this article

Table of contents

  • The usual scenario
  • The instinctive reaction - React.memo
  • A better way - children as a prop
  • React context

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! ๐Ÿ’ซ

Did you find this article valuable?

Support Iva Kop by becoming a sponsor. Any amount is appreciated!

See recent sponsors |ย Learn more about Hashnode Sponsors
ย 
Share this