TL;DR:
useEffect
will be called twice in React 18 dev strict mode, even with an empty deps list.We can write a custom hook as a workaround.
❓ Problem
Things started around 2 months ago, when I created a new Next.js app with React 18. I was in the dev mode, writing API fetching stuffs in useEffect
hook.
But soon I noticed that my API endpoint was hit twice after every page refresh: I saw two identical requests in the "Network" panel, along with two identical console.log
messages in the "Console" panel.
I didn't care much about it at that time, because I thought it was a problem on my side - maybe I wrote some bad codes on my backend or frontend. 😥 There wasn't any bad influence at that time, so I just ignored this issue.
But recently I was writing my own hooks collection, and this strange behavior really confused me a lot. After some searching, I found the reason in the official React 18 blog. (See link below!)
🔬 Previous behavior
Think about this piece of code. What's the result?
import { useEffect, useState } from "react";
export default function App() {
const [count, setCount] = useState(0);
const increment = () => setCount(count => count + 1);
useEffect(() => {
console.log("Effect is created");
increment();
return () => console.log("Effect is destroyed");
}, []);
return <button onClick={increment}>Count: {count}</button>;
}
The useEffect
hook's deps list is empty, so:
-
increment()
will only be called once, after the component is mounted, right? - The value of
count
should be1
, right? - We will see "Effect is created" in the console, right?
Well, it is true in most cases, but not in React 18 dev strict mode.
In React 18 dev strict mode, we will instead see:
-
increment()
is called twice. -
count
is2
. - Console:
Effect is created
Effect is destroyed
Effect is created
✨ New behavior
Let's see what the React blog said:
...
React 18 introduces a new development-only check to Strict Mode. This new check will automatically unmount and remount every component, whenever a component mounts for the first time, restoring the previous state on the second mount.
...
Unmounting and remounting includes:
componentDidMount
componentWillUnmount
useEffect
useLayoutEffect
useInsertionEffect
That's a brand new behavior! Our component mounts, then unmounts, and then mounts again, along with a second call to useEffect
.
For more information, please see Updates to Strict Mode.
🔧 Workaround
What if we really want our useEffect
to run only once? Here's a solution:
import { EffectCallback, useEffect, useRef } from "react";
export function useEffectOnce(effect: EffectCallback) {
// A flag indicating whether the effect has been executed or not.
const isExecuted = useRef(false);
useEffect(() => {
// If executed already, skip it this time.
if (isExecuted.current) {
return;
}
// Otherwise, just call it as normal.
effect();
// And set the flag to true.
isExecuted.current = true;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
}
👆 We can create a custom hook, and use a ref as a flag to indicate "we have already run this callback".
👀 CodeSandbox preview
👋 This is my first article on dev.to!
So thank you all for the patience of reading my article! English is not my mother tongue, so I probably wrote something weird above. 😂
And if there's anything wrong about this post, please leave your comment below! 👇 Any suggestions will be appreciated!
Top comments (1)
Thank you for the article, it was helpful