When React Hooks re-render components

Learn how hooks affect the component lifecycle, in which order they are called, and when they are executed.

React Hooks call order

Since the introduction of Hooks in React 16.8, the way developers write their components has changed. Hooks arguably improve the developer experience and help you save time writing unnecessary code and boilerplate.

But in order to achieve such greatness, some abrupt changes were required. The well-established paradigm of class components was ditched, and lifecycle hooks, which semantically make a lot of sense, were removed.

It is vital to understand that there is no such thing as a 1:1 map between an old lifecycle hook and new React Hooks. Indeed, you can leverage them to achieve the same effect, but ultimately, they are not the same thing.

The goal of this article — in addition to stressing that writing Hooks requires a different mindset than writing class components — is to give you a better overview of how, exactly, the whole React component lifecycle works. That is to say, in what order the code is being executed in your app.

Even if you think you know the answer, bear with me. You might find surprising results throughout this text.

The basics

As always, let’s start with the basics. React, as its name suggests, is reactive to changes — namely, to changes in either its props or state. A prop is an external variable passed to a component, and a state is an internal variable that persists across multiple renders.

Both props and state variables cause a re-render when they change; from this, it follows that the only difference between a state and a ref is that a ref change does not cause a re-render. More about refs here.

Consider the following example:

const CustomButton = () => {
  console.log("I'm always called");
  return <button>Click me</button>;
}

The above component has no props (arguments to the function, i.e., the component) and no state. This means that, as it stands, it will render only once.

Now, consider the version with a prop:

const CustomButton = ({color}) => {
  console.log("I'm called with color", color);
  return <button style={{color}}>Click me</button>;
}

If you render <CustomButton color="red" /> and then change it to <CustomButton color="blue" />, you will see two logs, one for each time the function (component) was called.

Let’s now introduce the concept of state. Consider the whole application below:

const App = () => {
  const [count, setCount] = useCount(0);
  const buttonColor = count % 2 === 0 ? "red" : "blue";
  console.log("I'm called with count", count);
  return (
    <div>
      <CustomButton color={buttonColor} onClick={() => setCount(count + 1)} />
      <p>Button was clicked {count} times</p>
    </div>
  );
}

const CustomButton = ({color, onClick}) => {
  console.log("I'm called with color", color);
  return <button style={{color}} onClick={onClick}>Click me</button>;
}

Now we have a nested component. We shall study how it reacts in several scenarios, but for now, let’s take a look at what happens when we mount <App />, i.e., when the application is rendered:

I'm called with count 0
I'm called with color "red"

If you click on the button once, note that the console output will include:

I'm called with count 1
I'm called with color "blue"

And for a second click:

I'm called with count 2
I'm called with color "red"

What is happening? Well, every time you click on the button, you change App‘s state. Because there was a state change (count was incremented), the component re-renders (the function runs again).

In this new execution, once count changes, buttonColor will also change. But note that buttonColor is passed as a prop to CustomButton. Once a prop changes, CustomButton will also re-render.

Before we dive deeper into Hooks, consider this extended version:

const App = () => {
  const [count, setCount] = useState(0);
  const buttonColor = count % 2 === 0 ? "red" : "blue";
  console.log("I'm called with count", count);
  render (
    <div>
      <CustomButton color={buttonColor} onClick={() => setCount(count + 1)} />
      <CustomButton color="red" onClick={() => setCount(count + 1)} />
      <p>Button was clicked {count} times</p>
    </div>
  );
}

const CustomButton = ({color, onClick}) => {
  console.log("I'm called with color", color);
  return <button style={{color}} onClick={onClick}>Click me</button>;
}

What do you think will happen now?

One fair observation would be to say that only the first CustomButton, which has a variable color, will change. However, that’s not what we see in practice: both buttons will re-render. In this particular case, onClick changes whenever count changes, and as we saw, a prop change will trigger a re-render.

We shall see, however, that having static props and state alone aren’t enough to prevent a re-render.

Understanding re-renders

Consider the following example:

const App = () => {
  const [count, setCount] = useCount(0);
  console.log("I'm called with count", count);
  render (
    <div>
      <button onClick={() => setCount(count + 1)}>Increment</button>
      <p>Button was clicked {count} times</p>
      <StaticComponent />
    </div>
  );
}

const StaticComponent = () => {
  console.log("I'm boring");
  return <div>I am a static component</div>;
}

Notice that StaticComponent has neither props nor state. Considering what we learned so far, one might imagine that it will render only once, and never re-render even if the parent App changes. Again, in practice, that’s not what happens, and I'm boring will be logged in the console every time the button is clicked. What’s going on?

Turns out that React re-renders every child of the tree whose parent is the component that changed. In other words, if A is the parent of B, and B is the parent of C, a change in B (e.g., a state variable being updated) will spare A from change but will re-render both B and C.

The following image from this great post by Ornella Bordino gives a good visual of the effect:

DOM tree visualization

Refer to the above post if you are interested in the implementation details.

The ingenuous reader is now asking: Is that a way to prevent this behavior? The answer, as you might expect, is yes. React has a built-in function called memo that basically memoizes the result of the component and prevents the re-render given that neither the props nor state change.

We could update the prior example to:

...
const StaticComponent = React.memo(() => {
  console.log("I'm boring");
  return <div>I am a static component</div>;
});

…and voilà! Our StaticComponent will render only once and never re-render, no matter what happens up in the tree.

A word of caution: React.memo should be used sparingly. Truth is, you may never need to use it at all! Its benefits will only show up for components that are large (so re-rendering them often is undesirable) and mostly static (because you do not want to make shallow comparisons every time). Use React.memo() wisely is a fine piece that describes exactly when this technique should or should not be used.

The following CodeSandbox wraps up our discussion about re-rendering. Notice that having React.memo() only prevents a re-render when the props won’t:

Note: As of the time of this writing, there’s a bug in CodeSandbox when the component is called twice when there is a state change. In a production environment, this won’t happen.

Call order

Now that we have a clear understanding of when components (functions) are rendered (executed) in React, we are ready to investigate the order in which they are called. Consider the following example:

const Internal = () => {
  console.log("I'm called after")
  return <div>Child Component</div>
}

const App = () => {
  console.log("I'm called first");
  return (
    <Internal />
  );
}

As you might expect, the console will log:

I'm called first
I'm called after

Now, we know that useEffect Hooks are called after the component is mounted (more about useEffect here). What do you expect will be the log for the following?

const Internal = () => {
  console.log("I'm called after")

  const useEffect(() => {
    console.log("I'm called after Internal mounts");
  });
  return <div>Child Component</div>
}

const App = () => {
  console.log("I'm called first");

  const useEffect(() => {
    console.log("I'm called after App mounts");
  });
  return (
    <Internal />
  );
}

Surprisingly:

I'm called first
I'm called after
I'm called after Internal mounts
I'm called after App mounts

This happens because useEffect is called in a bottom-up fashion, so the effects resolve first in the children, and then in the parent.

What do you think will happen if we add a callback ref?

const Internal = () => {
  console.log("I'm called after")

  const useEffect(() => {
    console.log("I'm called after Internal mounts");
  });
  return <div>Child Component</div>
}

const App = () => {
  console.log("I'm called first");

  const useEffect(() => {
    console.log("I'm called after App mounts");
  };
  return (
    <Internal ref={ref => console.log("I'm called when the element is in the DOM")} />
  );
}

The result:

I'm called first
I'm called after
I'm called when the element is in the DOM
I'm called after Internal mounts
I'm called after App mounts

This is tricky but important: it tells us when we can expect to access an element in the DOM, and the answer is after components are rendered but before the effects run.

useEffect order

We have seen that useEffect runs after the component mounts. But in which order are they called?

const App = () => {
  console.log("I'm called first");

  const useEffect(() => {
    console.log("I'm called third");
  };

  const useEffect(() => {
    console.log("I'm called fourth");
  };

  console.log("I'm called second");

  return (
    <div>Hello</div>
  );
}

No surprises here. Everything outside the effects will run first, and then the effects will be called in order. Notice that useEffectaccepts a dependency array, which will trigger the effect when the component first mounts and when any of the dependencies change.

const App = () => {
  const [count, setCount] = useState(0);
  const [neverIncremented, _] = useState(0);
  console.log("I'm called first");

  const useEffect(() => {
    console.log("I'm called second on every render");
  };

  const useEffect(() => {
    console.log("I'm called only during the first render");
  }, [];

  const useEffect(() => {
    console.log("I'm called during the first render and whenever count changes");
  }, [count];

  const useEffect(() => {
    console.log("I'm called during the first render and whenever neverIncremented changes");
  }, [neverIncremented];

  return (
    <div>
      <p>Count is {count}</p>
      <button onClick={() => setCount(count + 1)}>
        Click to increment
      </button>
    </div>
  );
}

If you run the above code, the output will be:

I'm called first
I'm called second on every render
I'm called only during the first render
I'm called during the first render and whenever count changes
I'm called during the first render and whenever neverIncremented changes

Finally, if you click on the button:

I'm called first
I'm called second on every render
I'm called during the first render and whenever count changes

A word of caution with callback refs

Callback refs can have unexpected behavior. We’ve seen before that they are called between components rendering and effects running. But there’s a special case when the component is updated:

const App = () => {
  const [count, setCount] = useState(0);

  console.log("I'm called first");

  return (
    <div>
      <p>Count is {count}</p>
      <button 
        onClick={() => setCount(count + 1)}
        ref={ref => {
          console.log("I'm called second with ref", ref);
        }}>
        Click to increment
      </button>
    </div>
  );
}

When the component first renders, the output will be:

I'm called first 
I'm called second with ref 

Which is precisely what you expect. Now check what happens when you click on the button and trigger a re-render:

I'm called first 
I'm called second with ref null
I'm called second with ref 

The callback is executed twice, and the worst thing about it is that the ref is null during the first execution! This is a common source of bugs when users programatically want to trigger some DOM interaction when the state changes (for example, calling ref.focus()). Check out a more detailed explanation here.

The following CodeSandbox summarizes what was explained in the previous sections:

Conclusion

React Hooks are great, and their implementation is straightforward. However, mastering a component’s lifecycle is not trivial, especially because old lifecycle hooks were deprecated. With patience, however, one can understand exactly what is going on in a React tree and even optimize if it ever comes to be a problem.

Personally, I have been working exclusively with the Hooks API since its first release and still find myself confused from time to time. Hopefully, this article will serve as a guide not only to me but to you as you get more comfortable and experienced with the marvelousness of Hooks.

Subscribe to Rafael Quintanilha

Don’t miss out on the latest articles. Sign up now to get access to the library of members-only articles.
john@example.com
Subscribe