Of all the hooks built into React, useEffect
is arguably the most difficult to understand. When I was learning React Hooks, I had just begun to get comfortable with class-based components and the lifecycle methods, such as componentDidMount
. Part of the difficulty I had when learning useEffect
was due to the fundamental differences between useEffect
and the legacy React lifecycle methods. The best tutorials I've read on useEffect
advise you to "unlearn what you have learned" in regard to lifecycle methods.
Dan Abramov has an excellent blog post on useEffect. It's very thorough, and thus a long read. This post will summarize many of the points Dan covers, and I'll cover some of the issues and solutions I've discovered while using useEffect
.
First, here is the function signature for useEffect
as a TypeScript definition:
type useEffect = (effect: EffectCallback, deps?: Array<any>) => void;
type EffectCallback = () => (void | (() => void));
EffectCallback
is our function to execute as the effect, which can optionally return a cleanup function that will be executed when the component unmounts, or when the effect is redefined. The optional second argument to useEffect
, deps
, is a "dependency array". If deps
is omitted, then the effect will be executed (and redefined) after every render. When deps
is included, the effect is only redefined and executed if any of the values provided to the array change from one execution to the next. Consequently, providing no values to the dependency array, []
, will result in the effect only being executed after the initial render. In determining if a dependency has changed, as far as I know, a strict equality comparison is performed (===
). Note that arrays, objects, and functions are only equal by reference. In some situations this can be problematic. This blog post provides several solutions:
Object & array dependencies in the React useEffect Hook
Why is it even necessary to have a dependency array? How could we be accessing stale values inside an effect?
Consider the following snippet of vanilla JS:
let arr = [];
let y = 0;
function pushFunc() {
y++;
let x = y;
arr.push(() => x);
}
pushFunc();
pushFunc();
console.log(arr[0]()); // 1
console.log(arr[1]()); // 2
We push two functions to an array, () => x
, and each time this function is created, it captures x
from its closure within pushFunc
. x
from the first execution of pushFunc
is not the same x
in the second execution of pushFunc
. When dealing with React, the same rules apply, whether those values come from props or state, as they're also just variables. This is because a React component is just a function, and plays by the same rules concerning function execution context.
1 render = 1 function call.
If we were to provide a function to useEffect
with no dependency array; useEffect(() => {...})
, the effect function we provide would be redefined after every render, receiving fresh values from the current execution context. The effect would also re-execute after every render. The dependency array serves two purposes:
Tell React when to execute our effect
Tell React when to redefine our effect
Example 1: Basic Usage with fetch
The most common use case for useEffect
is fetching data from an API, and then updating the state of a component to render that data in the UI.
function Todo({ id }) {
const [todo, setTodo] = useState();
useEffect(() => {
fetch(`/api/todos/${id}`)
.then(res => res.json())
.then(json => setTodo(json));
}, []);
if (!todo) return null;
return <div>{todo.title}</div>;
}
While we should avoid making too many comparisons to the class lifecycle methods, the above usage of useEffect
with an empty dependency array []
is the rough equivalent of componentDidMount
. The above does work in its current form, but we're lying to React about the dependency array. Running the above snippet through eslint
configured with the rule react-hooks/exhaustive-deps
gives us this warning:
React Hook useEffect has a missing dependency: 'id'.
Either include it or remove the dependency array
We can fix this to become:
useEffect(() => {
...
}, [id]);
By providing id
to the dependency array we are saying:
"Whenever id
changes, redefine and rerun this effect."
id
may or may not change depending on how the parent component of our Todo
component gets a todo id, and provides that prop. If our Todo
component were to receive a different id
prop, then we probably would want to fetch the todo corresponding to that new id, calling our effect provided to useEffect
again.
Technically setTodo
should be included in the dependency array too. However, since it is a function we get from our useState
hook, its identity is guaranteed to be stable, so it will never change. Furthermore, in newer versions of the react-hooks/exhaustive-deps
rule, the linter won't tell us to include a useState
set_
function, nor the dispatch
function returned by useReducer
. It's safe to omit these specific functions from the dependency array. Just not other functions, as we will see in the next section.
Example 2: Functions as Dependencies
Next, let's take a look at functions as effect dependencies:
function Todo({ id }) {
const [todo, setTodo] = useState();
function fetchTodo() {
return fetch(`/api/todos/${id}`);
}
useEffect(() => {
fetchTodo()
.then(res => res.json())
.then(json => setTodo(json));
}, []);
if (!todo) return null;
return <div>{todo.title}</div>;
}
In this example, our effect calls a function, fetchTodo
. This code contains a bug. 🐛 Because we omit fetchTodo
from our effect's dependency array, our effect captures only the original definition of fetchTodo
, and in turn, that instance of fetchTodo
only captures the initial value of the id
prop. If id
changes, our effect will reference the original stale value. Like in the first example, id
is a dependency we need to inform our effect about. The difference is, we've now made that dependency indirect by accessing id
inside fetchTodo
rather than directly inside our effect callback.
There's a problem with simply adding fetchTodo
to the dependency array to solve this. Because fetchTodo
will be redefined on each render / execution of our Todo
component, fetchTodo
will have a new "value" / "function identity" each time, resulting in the effect being triggered on every render. There are two solutions to this problem:
Solution #1
Include fetchTodo
in the dependency array, and define fetchTodo
with useCallback. Like useEffect
, useCallback
also accepts a dependency array. Because fetchTodo
references id
in its function body, we need to include id
in its dependency array:
const fetchTodo = useCallback(() => {
return fetch(`/api/todos/${id}`);
}, [id]); // Whenever `id` changes, `fetchTodo` will be redefined
useEffect(() => {
fetchTodo()
.then(res => res.json())
.then(json => setTodo(json));
}, [fetchTodo]); // Add `fetchTodo` to the effect's dependency array
Solution #2
The other solution is to extract fetchTodo
from the component entirely. Being outside the closure of the Todo
component, it won't have access to the id
prop, but we can supply that as an argument to the function. Extracting fetchTodo
will allow its function identity to be stable across renders of Todo
:
function Todo({ id }) {
const [todo, setTodo] = useState();
useEffect(() => {
fetchTodo(id) // Pass `id` as an argument
.then(res => res.json())
.then(json => setTodo(json));
}, [id]); // `fetchTodo` now has a stable function identity
if (!todo) return null;
return <div>{todo.title}</div>;
}
function fetchTodo(id) { // Make `id` an argument
return fetch(`/api/todos/${id}`);
}
Example 3: Access Updated props
Without Rerunning an Effect
Let's look at another example. This one is a fairly unique case, as we need to access updated values in our effect, but re-executing the effect actually breaks the functionality we're going for:
function Counter({ incrementBy }) {
const [num, setNum] = useState(0);
useEffect(() => {
const handle = setInterval(() => {
setNum(num + incrementBy);
}, 3000);
return () => clearInterval(handle);
}, [num, incrementBy]);
return <div>{num}</div>;
}
setNum
won't change, but num
and incrementBy
are both problematic. With num
in the dependency array, and our effect updating num
via setNum
, this will cause our effect to be triggered every time setNum(num + incrementBy)
is run. For setting state relying on previous state values, we can use the callback form of setState
, and remove num
as a dependency.
If num
is omitted from the dependency array, the linter will actually suggest this solution to us:
React Hook useEffect has a missing dependency: 'num'.
Either include it or remove the dependency array.
You can also do a functional update 'setNum(n => ...)'
if you only need 'num' in the 'setNum' call
To use the functional update form of setState
, we can change this to:
function Counter({ incrementBy }) {
const [num, setNum] = useState(0);
useEffect(() => {
const handle = setInterval(() => {
setNum(prevNum => prevNum + incrementBy); // `num` is no longer used here
}, 3000);
return () => clearInterval(handle);
}, [incrementBy]); // `num` removed from dependency array
return <div>{num}</div>;
}
Now we're left to deal with incrementBy
. If this prop is updated, say from 10
to 20
, we do want that updated value to be referenced in our effect, rather than referencing a stale value. However, when our effect is redefined, we lose the timing of our interval, and a new interval is created. We have it setup to call setNum(prevNum => prevNum + incrementBy)
every 3 seconds.
What happens if just 1.5 seconds have passed for the interval, and the value of incrementBy
changes?
Our effect cleanup function we provided to React will be executed,
() => clearInterval(handle)
, clearing our current 3 second interval.Our effect will be redefined, creating a new 3 second interval, along with a new cleanup function.
From there, 3 more seconds must pass before
setNum(...)
is called, for a total of 4.5 seconds since the last interval call (wrong behavior).
This example with setInterval
provides us with a unique challenge. We want the updated values present in our effect, but we don't want the timing of our interval to be messed up, as a result of redefining our effect. useReducer
can help us achieve this, by accessing updated props in our reducer function, rather than in useEffect
:
function Counter({ incrementBy }) {
const [num, incrementNum] = useReducer(
prevNum => prevNum + incrementBy, // Our "setter" (reducer function)
0 // Initial state
);
useEffect(() => {
const handle = setInterval(() => {
incrementNum();
}, 3000);
return () => clearInterval(handle);
}, []);
return <div>{num}</div>;
}
We use useReducer
in a similar fashion to useState
, but with the ability to specify what our "setter" function does, and for it to access updated props. useReducer
is flexible in how you use it for your state. It can be used for simple, single value state, or more complex state objects. By convention, you'd normally see useReducer
used like this: const [state, dispatch] = useReducer(...)
. We instead choose to name these num
& incrementNum
. incrementNum
is our dispatch
function that useReducer
returns to us, and it is guaranteed to have a stable function identity, preventing it from triggering useEffect
to rerun. Since incrementNum
is the dispatch
function returned to us by useReducer
, it can be omitted from the dependency array and the exhaustive deps linter won't complain.
Conclusion
Hopefully this post helped in understanding useEffect
. As you can tell, the design of this hook by the React team is something that's opinionated and strict in how it is intended to be used, though that's not a bad thing. Being honest about an effect's dependencies is important in avoiding subtle bugs. We looked at some tricks that can be used to limit the number of dependencies in our effects. These recommended workarounds to reduce dependencies are something I wish was documented a little better in the official React docs. One of the more helpful parts of the docs is the Hooks FAQ #Performance Optimizations section, which to me seems like more of a general usage guide. Knowing these recommended strategies for working with useEffect
is crucial, as I've found that it's very easy to "break the rules" of useEffect
when building real world applications.