Hooks are quite a new feature in React. They seem to simplify how we add logic to our components. When I was starting to use them, most of them seemed straightforward. That didn’t apply to the useEffect hook. It might not be that uncomplicated, and therefore, it needs more explanation. In this article, we look into React useEffect and explain some of its caveats. We also learn how it can be used to substitute for the lifecycle methods.
The purpose of the useEffect hook
Putting it simply, the useEffect lets us perform side effects in a component. An example of such might be fetching some data and listening for upcoming events.
Let’s look into an elementary example of using effects:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
import React, { useEffect, useState } from 'react'; import Todo from './Todo'; const TodoList = () => { const [todos, setTodos] = useState([]); useEffect(() => { fetch('https://jsonplaceholder.typicode.com/todos') .then(response => response.json()) .then(data => { setTodos(data); }) }); return ( <div> { todos.map(todo => ( <Todo key={todo.id} title={todo.title} completed={todo.completed} /> )) } </div> ) } export default TodoList; |
The code above does work, and we get a list of todos from the jsonplaceholder. There is a significant problem, though: the useEffect hook runs on every render, by default. It acts as the combination of the componentDidMount and componentDidUpdate methods.
useEffect dependencies
To customize the above behavior, we can use an array of dependencies as a second argument of the useEffect hook. With it, the callback runs only if some of the elements of the array change.
Since we want our hook to run only once, we can pass an empty array. This works similar to the componentDidUpdate method.
1 2 3 4 5 6 7 8 9 10 |
useEffect( () => { fetch('https://jsonplaceholder.typicode.com/todos') .then(response => response.json()) .then(data => { setTodos(data); }) }, [] ); |
There are often times when we do have some dependencies, and we want to run our effects more than once. A very common example is the one with modifying document.title.
1 2 3 |
useEffect((todos) => { document.title = `Fetched ${todos.length} todos` }) |
If we use the code above, it runs on every render. We don’t really need that!
1 2 3 |
useEffect((todos) => { document.title = `Fetched ${todos.length} todos` }, []) |
If we pass an empty array, two things happen. First, we would always see a message: “Fetched 0 todos”. That’s because it runs only once before we successfully fetch any todos.
The second thing is that we see a warning coming from the react-hooks/exhaustive-deps eslint rule.
1 |
React Hook useEffect has a missing dependency: 'todos.length'. Either include it or remove the dependency array react-hooks/exhaustive-deps |
A solution is to add the todos.length to dependencies.
1 2 3 |
useEffect(() => { document.title = `Fetched ${todos.length} todos` }, [todos.length]) |
Now our useEffect callback runs every time todos.length changes.
Putting the useCallback hook to use
The above is a straightforward example, but the dependencies are not always that obvious. Let’s complicate things a bit on purpose by introducing a function that isn’t pure.
If you want to know more about pure functions, check out Improving our code with pure functions
1 2 3 4 5 6 7 8 |
function useTodosCountDisplaying(todos) { function countCompletedTodos() { return todos.filter(todo => todo.completed).length; } useEffect(() => { document.title = `Fetched ${countCompletedTodos()} completed todos` }, []) } |
Now we see a warning, and the document.title does not update.
1 |
React Hook useEffect has a missing dependency: 'countCompletedTodos'. Either include it or remove the dependency array react-hooks/exhaustive-deps |
The above is not very accurate. Adding countCompletedTodos to dependencies won’t fix the error, and we still end up with a warning.
The ‘countCompletedTodos’ function makes the dependencies of useEffect Hook (at line 10) change on every render. Move it inside the useEffect callback. Alternatively, wrap the ‘countCompletedTodos’ definition into its own useCallback() Hook react-hooks/exhaustive-deps
We can either put the todos in the dependencies or follow the advice above and use the useCallback hook. It returns a memoized version of the callback that only changes if one of the provided dependencies change.
1 2 3 4 5 6 7 8 9 10 11 |
import { useEffect, useCallback } from 'react'; function useTodosCountDisplaying(todos) { const countCompletedTodos = useCallback(() => { return todos.filter(todo => todo.completed).length; }, [todos]); useEffect(() => { document.title = `Fetched ${countCompletedTodos()} completed todos` }, [countCompletedTodos]) } |
Now, countCompletedTodos changes only if todos change.
Designing custom hooks
Above, we use a custom hook. We can do the same with our previous code to make it more elegant.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
import { useEffect, useState } from 'react'; function useTodosLoading() { const [todos, setTodos] = useState([]); const [isLoading, setIsLoading] = useState(false); useEffect( () => { setIsLoading(true); fetch('https://jsonplaceholder.typicode.com/todos') .then(response => response.json()) .then(data => { setTodos(data); }) .finally(() => { setIsLoading(false); }) }, [] ); return { isLoading, areThereAnyTodos: todos && todos.length, todos } } export default useTodosLoading; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
import React from 'react'; import Todo from './Todo'; import useTodosLoading from './useTodosLoading'; import Loader from "./Loader"; const TodoList = () => { const { isLoading, todos, areThereAnyTodos } = useTodosLoading(); return ( <div> <Loader isLoading={isLoading}> { areThereAnyTodos ? todos.map(todo => ( <Todo key={todo.id} title={todo.title} completed={todo.completed} /> )) : <p>No Todos added!</p> } </Loader> </div> ) } export default TodoList; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
import React from 'react'; const Loader = ({ isLoading, children }) => { return ( isLoading ? <p>Loading...</p> : children ) } export default Loader; |
When creating a custom hook similar to the one above, we distinct the logic from a template. Thanks to that, we follow the separation of concerns principle.
There is a question that often pops up when creating a custom hook, such as the one above: should we return an object or an array? The hooks like useState use the latter, and it might seem like a standard approach. The above is not always the case: the useContext hook returns an object, for example.
The main advantage of returning an array is the fact that we can define the names of the elements of the array when destructuring.
1 2 |
const [todos, setTodos] = useState([]); const [isLoading, setIsLoading] = useState(false); |
It works very well with very generic hooks like the useState. Since our custom hook is quite specific and returns a few different properties, it might be better to return an object. Now, when we use the useTodosLoading hook, we don’t need to dwell on the order of the properties.
Cleaning up after a side effect
When performing a side-effect, we often need to clean up afterward. A good example of it is subscribing to some events. When our component unmounts, we would like to unsubscribe.
In order to perform a cleanup, our useEffect callback needs to return a function.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
import { useState, useEffect } from 'react'; function useWindowResizeListener() { const [width, setWidth] = useState(window.innerWidth); const [height, setHeight] = useState(window.innerHeight); function onResize() { setWidth(window.innerWidth); setHeight(window.innerHeight); } useEffect(() => { window.addEventListener('resize', onResize); return () => window.removeEventListener('resize', onResize); }, []); return { width, height } } export default useWindowResizeListener; |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
import React from 'react'; import useWindowResizeListener from './useWindowResizeListener'; const WindowSize = () => { const { width, height } = useWindowResizeListener(); return ( <div> <p>Width: {width}</p> <p>Height: {height}</p> </div> ) } export default WindowSize; |
Now, our onResize callback is removed from the window as soon as the WindowSize component unmounts.
The above is similar to using the componentWillUnmount method. There is a significant difference, though. Our useEffect hook might run multiple times before unmounting. Because of that, React also cleans up before running the effects next time. To observe this, let’s modify one of our previous examples.
1 2 3 4 5 6 7 8 9 10 11 12 |
import { useEffect } from 'react'; function useTodosCountDisplaying(todos) { useEffect(() => { document.title = `Fetched ${todos.length} todos`; return () => { console.log('Cleaned up!'); } }, [todos.length]) } export default useTodosCountDisplaying; |
When we run the above code in our TodoList, we can observe the Cleaned up! text in the console right away. This is because our useEffect callback runs twice.
First, it happens with no todos when todos.length is equal to 0. It occurs for the second time when the todos are successfully fetched.
Summary
In this article, we’ve gone through some of the common use-cases of the useEffect hook. When doing so, we’ve encountered some issues that we’ve sorted out. Throughout all of the above, we’ve learned how to use the useEffect hook instead of the lifecycle methods and how it differs from them. Also, to make our code more elegant by separating the logic from the template in the form of custom hooks. What is your approach to creating them?
I was always to lazy to check… but if you call setState twice one after another… doesn’t this trigger re-render twice? Or hooks are different about this?
state updates are bundled together, so setState multiple times in one render cycle will be queued for the next
There is a mistake in the guide.
“This works similar to the componentDidUpdate method.”
If you pass an empty array as a second argument in useEffect, the behaviour is similar to componentDidMount