DEV Community

Dennis Persson
Dennis Persson

Posted on • Updated on • Originally published at perssondennis.com

Understanding useEffect, useRef and Custom Hooks

In this article, we will learn at what time React invokes useEffect, useRef and custom hooks. A usePrevious hook will be used for demonstration.

A question I like to ask developers is "do you understand React's life cycle"? The answer is very often a confident "yes".

Then I show them the code for a usePrevious hook and let them explain why it works. If you don't know what a usePrevious hook is, you can see one below. It's used to get a previous value of a prop or state in a component, see React docs.

const usePrevious = (value, defaultValue) => {
  const ref = useRef(defaultValue);

  useEffect(() => {
    ref.current = value;
  }, [value]);

  return ref.current;
};
Enter fullscreen mode Exit fullscreen mode

Usually, the answer I get is a diffuse answer mentioning something about useRef updating instantly independent of the life cycle or that useRef doesn't trigger a rerender. That's correct.

Then I ask, "if the useEffect is updating the ref value as soon as the passed in value prop updates, won't the hook return the updated ref value?". The response is most often confusion. Even though my statement is fundamentally wrong, they don't really know React's life cycle well enough to explain what is wrong with my question. In fact, they most often believe that what I am saying is true and stands clueless of why the hook works.

Let us therefore take a look at how the usePrevious hook works. It's a perfect case for explaining how React handles useEffect and useRef.

Confusion meme
What did you previously say?

Logging the Sh*t Out of usePrevious

Here we have a simple React component, using a usePrevious hook. What it does is to increment a count when a button is clicked. It's an overcomplicated way to do such a thing, we wouldn't really need a usePrevious hook in this case, but since the topic under discussion is the usePrevious hook, the article would be quite boring if we left it out.

// ### App.js
// When the button is clicked, the value is incremented.
// That will in turn increment the count.

// import React, { useEffect, useState } from "react";
// import usePrevious from "./usePrevious";

export default function App() {
  const [value, setValue] = useState(0);
  const [count, setCount] = useState(0);

  const previouseValue = usePrevious(value, 0);

  useEffect(() => {
    if (previouseValue !== value) {
      setCount(count + 1);
    }
  }, [previouseValue, value, count]);

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setValue(value + 1)}>Increment</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

To better understand what React does when running the code, I have the same code here below but with a lot of console logs within it. I will carefully go through them all. You can find the code example at CodeSandbox if you want to elaborate on your own.

// ### App.js (with logs)
// When the button is clicked, the value is incremented.
// That will in turn increment the count.

// import React, { useEffect, useState } from "react";
// import usePrevious from "./usePrevious";

export default function App() {
  const [value, setValue] = useState(0);
  const [count, setCount] = useState(0);

  console.log("[App] rendering App");
  console.log("[App] count (before render):", count);
  console.log("[App] value:", value);
  const previouseValue = usePrevious(value, 0);
  console.log("[App] previousValue:", previouseValue);

  useEffect(() => {
    console.log("[App useEffect] value:", value);
    console.log("[App useEffect] previouseValue:", previouseValue);

    if (previouseValue !== value) {
      console.log("[App useEffect] set count to value:", value, "\n\n");
      setCount(count + 1);
    } else {
      console.log("[App useEffect] not increasing count");
    }
  }, [previouseValue, value, count]);

  console.log("[App] count (after render):", count);
  console.log("[App] done rendering App\n\n");

  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setValue(value + 1)}>Increment</button>
    </div>
  );
}

Enter fullscreen mode Exit fullscreen mode
// ### usePrevious.js (with logs)

// import { useRef, useEffect } from "react";

const usePrevious = (value, defaultValue) => {
  console.log("[usePrevious] value:", value);
  const ref = useRef(defaultValue);

  useEffect(() => {
    console.log("[usePrevious useEffect] value:", value);
    console.log("[usePrevious useEffect] increment ref.current:", ref.current);
    ref.current = value;
  }, [value]);

  console.log("[usePrevious] ref.current:", ref.current);

  return ref.current;
};

export default usePrevious;

Enter fullscreen mode Exit fullscreen mode

Enough of code now, I think. Let's look at what happens when we click the Increment button. Here's what we will see in the output console. I highly recommending opening a second browser window to keep the code visible while you read the rest of this article.

# App component renders (1)
[App] rendering App
[App] count (before render): 0
[App] value: 1
[usePrevious] value: 1
[usePrevious] ref.current: 0
[App] previousValue: 0
[App] count (after render): 0
[App] done rendering App

# useEffects run (2)
[usePrevious useEffect] value: 1
[usePrevious useEffect] increment ref.current: 0
[App useEffect] value: 1
[App useEffect] previouseValue: 0
[App useEffect] set count to value: 1

# App component rerenders again (3)
[App] rendering App
[App] count (before render): 1
[App] value: 1
[usePrevious] value: 1
[usePrevious] ref.current: 1
[App] previousValue: 1
[App] count (after render): 1
[App] done rendering App

# useEffects run again (4)
[App useEffect] value: 1
[App useEffect] previouseValue: 1
[App useEffect] not increasing count

# (5)
Enter fullscreen mode Exit fullscreen mode

Note: The description that follows should be treated as an interpretation of the code and output above. It's not the exact algorithm React uses. More about that later.

(1) So here's what happens. When we click the increase button, it updates the value state to 1 which triggers a rerender of the App component. The usePrevious hook is the first code to be reached in the rerender, so it gets invoked directly. In that hook, we get the updated prop value of 1 while ref.current is still the default value of 0. React notes that the dependency to useEffect has changed, but it doesn't trigger the useEffect yet. Instead, it returns the ref.current value of 0 from the hook and store it in previousValue variable.

The rendering of the App component continuous and it reaches the useEffect. At this time, value has been updated from 0 to 1, so the useEffect should be triggered, but not yet. Instead of triggering it, React completes its rendering with a default count value of 0.

Dependencies noted meme
React notes that a dependency has updated, but does not run the effect immediately

(2) Now, after having completed the rerender of the App component, it's time to run useEffects. React has noted that both the useEffect in usePrevious hook and in App component should be triggered. It starts invoking the useEffect in the usePrevious hook, that's the useEffect that was reached first during rendering.

When it runs the useEffect code it updates the ref.current to 1 and that's all. React continuous with the next useEffect in line, the one in the App component. At the time when the App component was rerendered and React first noticed that a value in the dependency list had updated, the previousValue variable was still set to 0. The reason we triggered the useEffect was because value had incremented from 0 to 1. So, the if-statement comparing value with previousValue will be truthy and we will update the count from 0 to 1.

(3) We have now emptied the useEffects queue. No more effects to trigger. React can now check if a rerender is required, and it will notice that it is. setCount has been invoked so the count variable has updated to 1 from 0, so React decides to rerender the component once again.

The state variable value is still 1, we haven't increased that value. This time usePrevious hook gets invoked with the same value as last rendering, so there's no need to trigger the useEffect in the usePrevious hook. ref.current still has a value of 1, so the previousValue variable will be assigned a value of 1. When we then reach the useEffect in App component, React notes that previousValue has updated but does nothing about it. It continues the rerendering of the App component and exits gracefully with a count of 1.

(4) Rerendering has completed, but we do have a useEffect in queue to run. As mentioned, the useEffect in usePrevious had no reason to trigger, so React continues directly with the effect in App component. previousValue is now 1, that's why we triggered the useEffect. value hasn't changed though and is still set to 1, so we don't invoke the setCount function.

(5) We are now done running the useEffects, so it's time for React to check if a rerender is required again. It isn't though, since neither value or count did update when we ran the effects. So React calms down and waits for further user input.

What Does the Life Cycle Look Like?

What I did describe above is not a technical description of React's life cycle, rather it's an interpretation of what happens when the code runs. There's no time for a detailed explanation of what the React code really looks like here. It's obviously a bit more advanced than I describe in this article. We would need a more complex example which includes child components etc., and we would need to talk about render and commit phase. For those who are interested, a brief explanation of that can be found here.

Anyhow, for the sake of helping you understand the execution order I described in the five steps above, I will summarize it with some pseudocode.

const rerender = () => {
    // run code in component

    // if we reach a useEffect
    if (useEffectDependenciesHasUpdated) {
        useEffectQueue.push(useEffectCode)
    }

    // continue running code in component
}

const reactLifeCycle = () => (
    while (true) {
        if (stateHasChanged) {
            rerender()
            runEffectsInQueue()
        }
    }
)
Enter fullscreen mode Exit fullscreen mode

As you can see, the above pseudocode is sufficient to explain why the usePrevious hook works. On a basic level, the life cycle could be expained in this way. React renders a component and runs the code within it. Whenever a useEffect is reached, react looks at its dependency list. If a variable within the dependency list has changed, React adds the callback function in the useEffect to a queue.

Whenever the rerendering has completed, react starts to pop effect callbacks out of that queue and invoke them. When the queue gets empty, React starts checking if it is necessary to rerender any components again.

Why My Question was Faulty

In the beginning of the article, I explained how I asked people this question about the usePrevious hook. Are you able to explain what it is wrong with the question now?

if the useEffect is updating the ref value as soon as the passed in value prop updates, won't the hook return the updated ref value?

Well, the answer to the question is actually: yes. If the useEffect was updating the ref value as soon as the passed in value updated, then yes, in that case, we would return the updated ref value. But that's not how React works. The useEffect isn't invoked instantly. It's invoked after React has completed the rendering phase and the parent component already has read the old ref value.

Conclusion

There are many things to say about React's life cycle handling. In this article we only look at useEffect, useRef and a custom usePrevious hook to see in which order React runs the code.

What we can discover by using a custom usePrevious hook is that React invokes the custom hook as soon as it reaches it during the rendering phase. The hook is merely a piece of code lifted out of the component.

However, at the time we reach a useEffect hook, React seemingly does nothing at all, rather it waits for the component rendering to finish, and then first after that has finished, the callback in the useEffect gets invoked.

I said seemingly nothing at all, because it's how it appears to work. Internally React handles many things under the hood. The dependency list must be checked in order to know if we even should run the callback or not. React must also keep track of the old dependencies to be able to compare them. But that's a topic for another day. What you need to know today, is that useEffect callbacks are invoked after a component has finished rendering, and they are executed in the same order as the code reaches them.

When a useEffect has run, the component may rerender a second time if its state has updated, e.g. if a set function returned by a useState has been invoked. If a useEffect only updates a useRef value, then React won't rerender the component. That value is updated immediately.

Thanks for reading,
Dennis

Top comments (4)

Collapse
 
koheejs profile image
koheejs

Thanks for the helpful topic. It's very clear.
BTW, I have a concern when I implement a simple component, I'm not sure why the useEffect callback is fired after the third re-render instead of after the second re-render as I thought.
This is my code

let count = 0;

export default function Test({ propValue }) {
  const [prevValue, setPrevValue] = useState(propValue);

  count += 1;
  console.log("   ");
  console.log("Start render:", count);

  useEffect(() => {
    console.log("Effect PROP-Change:", { prop: propValue, state: prevValue });
  }, [propValue]);

  useEffect(() => {
    console.log("Effect STATE-Change:", { prop: propValue, state: prevValue });
  }, [prevValue]);

  useEffect(() => {
    console.log("Effect ON-MOUNT");
  }, []);

  if (prevValue !== propValue) {
    console.log("Set state handler");
    setPrevValue(propValue);
  }

  console.log("Render:", { prop: propValue, state: prevValue });
  console.log("End render:", count);
  console.log("----");

  return <div>{propValue}</div>;
}
Enter fullscreen mode Exit fullscreen mode

And that is my console.log result, - assume I changed this prop one time

Start render: 1
Render: {prop: "123", state: "123"}
End render: 1
---- 
Effect PROP-Change: {prop: "123", state: "123"}
Effect STATE-Change: {prop: "123", state: "123"}
Effect ON-MOUNT 

Start render: 2
Set state handler 
Render: {prop: "456", state: "123"}
End render: 2
---- 

Start render: 3
Render: {prop: "456", state: "456"}
End render: 3
---- 
Effect PROP-Change: {prop: "456", state: "456"} // I thought it should be rendered in the second re-render
Effect STATE-Change: {prop: "456", state: "456"}

Enter fullscreen mode Exit fullscreen mode

Could you help explain what happen behind the scene for this issue, thanks so much

Collapse
 
perssondennis profile image
Dennis Persson

@koheejs

Oh I see, think I was too quick there to blame strict mode. In this case it's just because of the setState. Since you are setting the new state, React knows that the component will need to render another time, so it goes on and updates the component's state before it starts to run any effects. The effects then run after the component has reached the state it aimed for.

If React wouldn't have waited for the state update to render, the useEffect would have triggered even more times, especially for complex components.

Collapse
 
koheejs profile image
koheejs

Thanks @perssondennis for a great explanation

I just tried with more states and updated them frequently in the function, and as you said, all useEffect callbacks will be triggered after all re-rendering processes are done.

Collapse
 
perssondennis profile image
Dennis Persson

Hello @koheejs :)

The weird behavior your are seeing is likely because you use React 18 and have Strict Mode enabled in your index.js file. It will re-render components and run effects twice in development mode.

See the docs: react.dev/reference/react/StrictMo...

This article was written for React 17, and strict mode came with React 18. You can read my other article too see what kind of problems you may face with useEffect hook. The juice of that article is more or less that you shouldn't think about how many times a useEffect is rendered, instead, you should ensure that your app work appropriately regardless of how many times it renders.