Understanding useEffect, useRef and Custom Hooks
Dennis Persson
Posted on March 7, 2022
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;
};
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.
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>
);
}
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>
);
}
// ### 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;
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)
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.
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()
}
}
)
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
Posted on March 7, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.