The useEffect
hook is probably one of the more confusing React hooks. At first, we wonder when to use it, then we struggle to understand how to use it, and eventually, the guilt kicks in, and we ask how to test it.
How do I test the useEffect
hook?
The answer to the question is relatively short:
You don’t.
At least not directly.
But understanding the why and what to do instead requires a bit more explanation.
Today, we are going to explore the answer by solving a real problem. And we will solve this problem with a test-first approach.
By the end of this article, you will know:
as well as:
- When to use
useEffect
- How to use the
useEffect
dependency array - How to execute asynchronous code within
useEffect
- How to handle infinite rendering loops
- How to deal with a
not wrapped in act(..)
warning
If you cannot wait to get the answers, feel free to jump to the summary at the end.
The problem
Usually, we don’t get up in the morning with the desire to use an Effect Hook.
*yawn* … *stretch* … Today, I am going to use
useEffect
!
No! We get up with the intention to solve a real problem for actual end-users.
So, here’s our problem for today:
As a teacher, I want to quickly find students by name so that I don’t have to scroll through the whole list of students.
Just to give you an idea of where the journey will go, in the end, the user will get something like this:
We will display a list of students, loaded asynchronously from a backend, and later add the ability to request a filtered list of students.
Setup
We will solve this problem with React and Jest. So let’s set up the project. Feel free to code along. All you need is a working Node.js installation.
Let’s create a React project, then switch into the project folder, and let’s start the test suite:
npx create-react-app students
cd students
npm test
Test #1 – No students initially
Let’s start with writing a test, that will ensure we’re going to implement the right thing – no more, no less.
Here’s the test file:
touch src/Students.test.js
And here’s the first test:
// src/Students.test.js
describe('Students', () => {
it('shows no students initially', () => {
// ...
})
})
The Students
component shows no students initially.
Let’s fill the test with live:
// src/Students.test.js
import { render, screen, within } from '@testing-library/react'
import Students from './Students'
describe('Students', () => {
it('shows no students initially', () => {
render(<Students />)
const students = screen.getByRole('list', { name: /students/i })
expect(within(students).queryByRole('listitem'))
.not.toBeInTheDocument()
})
})
First, we render the Students
component. Then we get the list of students. And finally, we expect
that within
the student list, there is no listitem
.
The test fails:
FAIL src/Students.test.js ● Test suite failed to run Cannot find module './Students' from 'src/Students.test.js'
There is no Students
component yet. Let’s add it:
touch src/Students.js
And let’s try to make the test pass:
// src/Students.js
const Students = () => (
<>
<h1 id='students.title'>Students</h1>
<ul aria-labelledby='students.title' />
</>
)
export default Students
We simply added an unordered list without list items. And we gave the list a name by connecting the <ul>
tag with the <h1>
tag (if you want to learn more about creating accessible components with React Testing Library, check out this article series.
Now the test passes:
PASS src/Students.test.js Students ✓ shows no students initially
The code looks good. It’s time for the next test.
Test #2 – Loading zero students
What’s the next simplest thing? Let’s make sure that our component shows no students when the backend has no students:
// src/Students.test.js
import { render, screen, within } from '@testing-library/react'
import Students from './Students'
const { stringContaining } = expect
describe('Students', () => {
// ...
it('shows no students after loading zero students', () => {
fetch.mockResponse('[]')
render(<Students />)
const students = screen.getByRole('list', { name: /students/i })
expect(within(students).queryByRole('listitem'))
.not.toBeInTheDocument()
expect(fetch).toHaveBeenCalledWith(stringContaining('/students'))
})
})
We are going to use fetch
to make HTTP requests to the backend. But our component tests would be too slow if we tried to hit the backend with actual requests all the time. That’s why we mock a response. In the first line of the test, we tell fetch
to fake a response that contains an empty array.
The rest of the test is straightforward: Render the component. Get the list of students and verify that there are no list items in it. And in the end, we also check that fetch
was actually called by our component –with an URL containing the path /students
.
The test fails:
FAIL src/Students.test.js Students ✓ shows no students initially ✕ shows no students after loading zero students ● Students › shows no students after loading zero students TypeError: fetch.mockResponse is not a function
Ah yes, mocking fetch
requires a library. Let’s install it:
npm install --save-dev jest-fetch-mock
And let’s configure it by adding the following statement to the already existing test setup file:
// src/setupTests.js
// ...
// fetch mock
global.fetch = require('jest-fetch-mock')
And let’s do one thing every test suite that uses mocking should do: Let’s reset the mock between tests, and let’s make sure it has a default behavior for tests like our first test that doesn’t care about the fetch:
// src/Students.test.js
// ...
beforeEach(() => {
fetch.resetMocks()
fetch.mockResponse('[]')
})
describe('Students', () => {
// ...
})
Not resetting mocked functions between tests often leads to tests that fail for no apparent reason. Countless developer hours have been wasted by chasing a bug that wasn’t there. The problem usually is a mocked function programmed by the previous test to return a value that makes no sense for the current test.
Always reset your mocks between tests!
Now the test fails at the last expectation. The reason is that our component actually never called fetch
:
FAIL src/Students.test.js Students ✓ shows no students initially ✕ shows no students after loading zero students ● Students › shows no students after loading zero students expect(jest.fn()).toHaveBeenCalledWith(...expected) Expected: StringContaining "/students" Number of calls: 0
So let’s call fetch
to satisfy the test:
// src/Students.js
const Students = () => {
fetch('/students')
return (
// ...
)
}
export default Students
Now both tests pass:
PASS src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students
Calling fetch('/students')
was all we needed to do. We are not evaluating the response (or the empty array in the response), and useEffect
is also not to be seen anywhere yet.
The component as it stands is obviously not a working solution to our problem. But our tests are happy!
As test-first programmers, we never do any more than necessary to make our existing tests pass. If we are not satisfied with the solution, it simply means that we are not done writing tests.
Test #3 – Loading one student
Now, let’s up the game – but just a little. Let’s write a test to verify that our component can display one student after loading – well – one student.
We could jump right to supporting a list of students, but believe me, handling one student comes already with 4! challenges we need to overcome. Only a fool would try to tackle more challenges at once.
Here’s the third test:
// src/Students.test.js
it('shows one student after loading one student', () => {
fetch.mockResponse(JSON.stringify([ { name: 'John' } ]))
render(<Students />)
const students = screen.getByRole('list', { name: /students/i })
expect(within(students).getByRole('listitem', { name: /John/ }))
.toBeInTheDocument()
})
First, we configure fetch
to return an array with one object, representing the student with the name John
. Then we render the Students
component as before. We get hold of the list of students. And finally, we expect the list to contain a list item that has the word John
somewhere in its name.
The test fails:
FAIL src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students ✕ shows one student after loading one student ● Students › shows one student after loading one student TestingLibraryElementError: Unable to find an accessible element with the role "listitem" and name `/John/`
There is no list item labeled with John
. Fair enough, we haven’t implemented that yet. And here we go:
<ul aria-labelledby='students.title'>
<li aria-labelledby='students[1]'>
<span id='students[1]'>John</span>
</li>
</ul>
Hard-coding a list item with the name John
works, but it breaks the previous tests that expect no list items:
FAIL src/Students.test.js Students ✕ shows no students initially ✕ shows no students after loading zero students ✓ shows one student after loading one student
We have to make the list item conditional. First, there are no students, then there is one student. Something changes over time. That means we need state.
// src/Students.js
import { useState } from 'react'
const Students = () => {
const [ hasStudents, setHasStudents ] = useState(false)
fetch('/students')
return (
<>
<h1 id='students.title'>Students</h1>
<ul aria-labelledby='students.title'>
{hasStudents && (
<li aria-labelledby='students[1]'>
<span id='students[1]'>John</span>
</li>
)}
</ul>
</>
)
}
export default Students
When there are students, we render the list item, otherwise not.
But how to change that state? The second test expects no students to be displayed, and the third test expects one student to be displayed. It looks like we need to finally look at the response of fetch
:
// src/Students.js
import { useState } from 'react'
const Students = () => {
const [ hasStudents, setHasStudents ] = useState(false)
const response = await fetch('/students')
const students = await response.json()
setHasStudents(students.length > 0)
// ...
}
So we are awaiting the asynchronous response. Then we are unpacking the response body as JSON. That’s also an asynchronous process because the body is streamed (and potentially not fully received yet). Finally, we are setting hasStudents
to either true
or false
.
The problem is, this code will not compile. await
can only be used within a function that is marked as async
. The surrounding function is the functional component Students
, but React wouldn’t know how to handle async
components (e.g. const Students = async () => { /* ... */ }
would work).
So let’s move the statements for loading students into an async
helper function loadStudents
. And let’s use Promise.then(..)
to set hasStudents
after the students have been loaded (there are, of course, alternative ways of solving this part of the problem).
// src/Students.js
import { useState } from 'react'
const loadStudents = async () => {
const response = await fetch('/students')
return await response.json()
}
const Students = () => {
const [ hasStudents, setHasStudents ] = useState(false)
loadStudents()
.then(students => setHasStudents(students.length > 0))
// ...
}
Running the tests again reveals two problems: a) the third test is failing again (the first two tests are back to green) and b) a strange error you may have encountered before:
console.error
Warning: An update to Students inside a test was not wrapped in act(...).
When testing, code that causes React state updates should be wrapped into act(...):
act(() => {
/* fire events that update state */
});
Often we encounter this error when a component’s state changes after the tests have finished or when we try to interact with a component that has not settled yet and is still in the process of updating. The error usually happens when the state change occurs asynchronously.
In this case, we need to wait in our test for all asynchronous processes to settle. We need to wait until the component has reached its final state and until it has rerendered.
Unfortunately, the error message leads us down the wrong path. It says we have to wrap something in act(..)
, but if you use React Testing Library, you actually shouldn’t!
When using React Testing Library, there’s a better way:
We should prefer using await findBy*(..)
(which deals with act(..)
under the hood).
// src/Students.test.js
it('shows one student after loading one student', async () => {
fetch.mockResponse(JSON.stringify([ { name: 'John' } ]))
render(<Students />)
const students = screen.getByRole('list', { name: /students/i })
expect(await within(students)
.findByRole('listitem', { name: /John/ })).toBeInTheDocument()
})
Now that we wait for John
to be in the document eventually (now that we wait for all asynchronous processes to settle), all three tests pass:
PASS src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students ✓ shows one student after loading one student
Supporting one student was not as straightforward as you might’ve thought. Good that we didn’t jump right into supporting many students.
Test #4 – Loading many students
Now let’s support more than just one student:
// src/Students.test.js
it('shows many students after loading many students', async () => {
fetch.mockResponse(JSON.stringify(
[ { name: 'John' }, { name: 'Jane' } ]
))
render(<Students />)
const students = screen.getByRole('list', { name: /students/i })
expect(await within(students).findAllByRole('listitem'))
.toHaveLength(2)
expect(within(students)
.getByRole('listitem', { name: /Jane/ })).toBeInTheDocument()
})
First, we mock two students coming back from the API. And after rendering Students
, we continue with expecting two list items and one of the list items being our second student Jane
.
We use again async findBy*(..)
to wait until the fetch
has finished and the DOM has settled. Notice, that the second expect
does not use async findBy*(..)
. Instead, we prefer the synchronous getBy*(..)
. All asynchronous processes have finished during the first expect
. The DOM has settled, there’s no additional user interaction, so it’s safe to continue validating synchronously.
The test fails.
FAIL src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students ✓ shows one student after loading one student ✕ shows many students after loading many students ● Students › shows many students after loading many students expect(received).toHaveLength(expected) Expected length: 2 Received length: 1 Received array: [
There’s only one student. John.
Let’s fix that:
// src/Students.js
const Students = () => {
const [ students, setStudents ] = useState([])
loadStudents().then(setStudents)
return (
<>
<h1 id='students.title'>Students</h1>
<ul aria-labelledby='students.title'>
{students.map(({ name }) => (
<li key={name} aria-labelledby={`students.[${name}]`}>
<span id={`students.[${name}]`}>{name}</span>
</li>
))}
</ul>
</>
)
}
So rather than using the state to manage whether there was at least one student, let’s manage the array of students
.
const [ students, setStudents ] = useState([])
loadStudents().then(setStudents)
In the beginning, it’s empty, but once loadStudents()
returns a list of students, we replace the empty list with the help of setStudents
.
And rendering multiple list items is done the React way – by using map
:
{students.map(({ name }) => (
<li key={name} aria-labelledby={`students.[${name}]`}>
<span id={`students.[${name}]`}>{name}</span>
</li>
))}
Replace every student in the list with an <li>
tag and replace all references to the student’s name with the student’s name.
Don’t forget about the key
property that will allow React to distinguish the different list items and perform speed optimizations. The key must be unique within the list, and to keep things simple, let’s assume that in this example, the name
property of a student is unique.
So, is the new test passing?
RUNS src/Students.test.js
The tests are still running…
Looks like we created something like an endless loop.
Let’s use CTRL+C
to kill the process (or close and reopen the terminal window).
What went wrong?
Let’s step back and regroup.
The problem is that we created an infinite loop of rerenderings.
// src/Students.js
const Students = () => {
const [students, setStudents] = useState([])
loadStudents().then(setStudents)
return (
// ...
)
}
When we render the component, the functional component function Students
will be executed. It triggers an asynchronous fetch
of students. The rendering of the component ends with returning the list markup we want to show to the user.
Eventually, the asynchronous fetch
that was triggered earlier completes. We take the students, and we change the state with setStudents
.
But a state change causes React to rerender the component. That means we trigger another asynchronous fetch
. And so on, and so on…
Introducing useEffect
So we are loading students on every render. That’s not exactly what we had in mind.
But what do we want instead?
We want the component to load the students only in the beginning.
And how do we do things in functional components when we want these things to happen only once?
This is where useEffect
enters the room. One use case of React’s useEffect
hook is precisely that: executing things once – when the component mounts.
So let’s wrap our fetch
in an useEffect
:
const Students = () => {
const [ students, setStudents ] = useState([])
useEffect(() => {
loadStudents().then(setStudents)
}, [])
// ...
}
We can use the useEffect
hook to execute side-effects – especially asynchronous side-effects like loading data from a backend.
One important thing to notice is the second argument to useEffect
– the empty array. An empty array indicates to React that this effect has no dependencies and therefore should fire only once on the initial render.
If you omit this second argument (a common beginner’s mistake), you’re telling React to fire this effect on every render. That’s in 99.9% of the cases, not what you want, and it would lead – again – to an infinite rerender.
Let’s restart the tests:
npm test
Now they are passing without getting stuck:
PASS src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students ✓ shows one student after loading one student ✓ shows many students after loading many students
Yay!
What’s next?
Here’s again our problem for today:
As a teacher, I want to quickly find students by name so that I don’t have to scroll through the whole list of students.
We need to support some kind of filtering.
Test #5 – Showing filtered students
Let’s test that when the teacher enters a search team, the component actually uses that search term to display a filtered list of students:
// src/Students.test.js
it('shows filtered students only', async () => {
fetch.mockResponseOnce(JSON.stringify([
{ name: 'John' }, { name: 'Jane' }
]))
fetch.mockResponse(JSON.stringify([
{ name: 'Jane' }
]))
render(<Students />)
const students = screen.getByRole('list', { name: /students/i })
expect(await within(students)
.findByRole('listitem', { name: /John/ })).toBeInTheDocument()
userEvent.type(screen.getByPlaceholderText(/filter/i), 'Ja')
await waitForElementToBeRemoved(within(students)
.getByRole('listitem', { name: /John/ }))
})
This time we are programming fetch
to first return a list with two students and then return a list with only one student.
We render Students
and wait until the first fetch
(on mount) has completed, the component has rerendered, and we see John
.
Eventually, we type into a filter text field, and we wait for John
to disappear. Notice how we use await waitForElementToBeRemoved(..)
to verify that an element is not present after it has been removed asynchronously (when the second fetch
has completed).
The test fails. It cannot find an element with the word /filter/i
anywhere in its placeholder.
Let’s add it:
// src/Students.js
const Students = () => {
const [ students, setStudents ] = useState([])
const [ filter, setFilter ] = useState('')
// ...
return (
<>
<h1 id='students.title'>Students</h1>
<ul aria-labelledby='students.title'>
{ /* ... */ }
</ul>
<input
type='text'
placeholder='filter'
value={filter}
onChange={e => setFilter(e.target.value)}
/>
</>
)
}
We add an <input>
field that is controlled by some new state called filter
. The field shows filter
as value. And changing the text in the field will result in onChange
events that result in setFilter
being called to update filter
.
Now the test fails for a different reason:
FAIL src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students ✓ shows one student after loading one student ✓ shows many students after loading many students ✕ shows filtered students only ● Students › shows filtered students only Timed out in waitForElementToBeRemoved. # ... <li aria-labelledby="students[John]"> <span id="students[John]">John</span> </li> <li aria-labelledby="students[Jane]"> <span id="students[Jane]">Jane</span> </li> # ...
Our student John
is still on the list!
That means fetch
has not been called a second time after the filter
changed.
So how can we achieve that?
It’s again useEffect
that can help us here. And the solution is actually surprisingly simple:
useEffect(() => {
loadStudents().then(setStudents)
}, [ filter ])
We add the filter
property to the array of the effect’s dependencies. Now useEffect
fires on the first render (when the component mounts) and whenever the value of filter
changes.
Here’s again what’s going on in our test and our component:
Initially, the component shows no students. But the effect fires and triggers a fetch
. The request returns with a mocked response of two students. students
changes, the component rerenders, and two list items will be added to the list.
Then, we simulate the user typing a filter term. filter
changes, the component rerenders, the effect fires again and triggers a second fetch
. This time the mocked response contains only one student. students
changes, the component rerenders, and one list item will be removed from the list.
The test finally passes:
PASS src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students ✓ shows one student after loading one student ✓ shows many students after loading many students ✓ shows filtered students only
Everything works, right?
Wait a moment.
We actually don’t use the filter
in our fetch
request:
useEffect(() => {
loadStudents().then(setStudents)
}, [ filter ])
Yes, the effect fires when filter
changes, but it does not pass filter
to loadStudents()
.
But why does it work?
That’s a common issue developers run into when they use mocking. The danger with mocking is that it’s relatively easy to accidentally over-simplify how the world works.
We told our fetch
mock to first return a list with John
and then without John
:
fetch.mockResponseOnce(JSON.stringify([
{ name: 'John' }, { name: 'Jane' }
]))
fetch.mockResponse(JSON.stringify([
{ name: 'Jane' }
]))
But the fetch
mock – like all mocks – is stupid. It will return the list without John
regardless of whether the call to fetch
contains a filter or not. The only thing that counts here is the order of the fetch
calls.
Final Test #6 – Loading filtered students
Currently, we have no good reason to pass the filter
to loadStudents()
. All tests are passing at the moment. The Test-first approach prevents us from making the code more complex without a good reason. First, we need another test that makes the problem visible:
// src/Students.test.js
it('uses filter to load students', async () => {
fetch.mockResponseOnce(JSON.stringify([
{ name: 'John' }, { name: 'Jane' }
]))
fetch.mockResponse(JSON.stringify([
{ name: 'Jane' }
]))
render(<Students />)
const students = screen.getByRole('list', { name: /students/i })
expect(await within(students)
.findByRole('listitem', { name: /John/ })).toBeInTheDocument()
userEvent.type(screen.getByPlaceholderText(/filter/i), 'Ja')
await waitFor(() => expect(fetch)
.toHaveBeenCalledWith(stringContaining('?name=Ja')))
})
This test is very similar to the previous one. The test setup (mocking of fetch
) is identical, and the test execution (rendering and entering a search term) is the same as well.
The only difference is the verification:
await waitFor(() => expect(fetch)
.toHaveBeenCalledWith(stringContaining('?name=Ja')))
This time we wait for fetch
to have been called with a query parameter name
that has the search term Ja
as value.
We use await waitFor(..)
because we typed a search term in the previous step. Typing a search term results in filter
to be updated, and this causes the component to rerender. useEffect
fires again and eventually causes an asynchronous update of students
.
Remember: With React Testing Library, you will never use act(..)
. Instead, you will use await findBy*(..)
, await waitFor(..)
, or await waitForElementToBeRemoved(..)
.
How’s the test doing?
FAIL src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students ✓ shows one student after loading one student ✓ shows many students after loading many students ✓ shows filtered students only ✕ uses filter to load students ● Students › uses filter to load students expect(jest.fn()).toHaveBeenCalledWith(...expected) Expected: StringContaining "?name=Ja" Received 1: "/students" 2: "/students" 3: "/students" Number of calls: 3
It fails. There were three calls to fetch
, but non with a query parameter name
.
Three calls? Shouldn’t it be two calls?
You might think. The two last calls actually come from us typing first the letter J
and then the letter a
. userEvent
is really good at simulating what really happens once we run our code in a browser.
Today, we can accept that. I’ll leave optimizing the additional fetch
call away to you.
Let’s make this last test pass:
// src/Students.js
const loadStudents = async filter => {
const query = filter ? `?name=${filter}` : ''
const response = await fetch('/students' + query)
return await response.json()
}
const Students = () => {
// ...
useEffect(() => {
loadStudents(filter).then(setStudents)
}, [ filter ])
/// ...
}
Let’s pass filter
to loadStudents()
, and let’s add a name
query parameter if filter
is set (truthy).
Finally. All tests pass:
PASS src/Students.test.js Students ✓ shows no students initially ✓ shows no students after loading zero students ✓ shows one student after loading one student ✓ shows many students after loading many students ✓ shows filtered students only ✓ uses filter to load students
Here’s our final component:1
// src/Students.js
import { useEffect, useState } from 'react'
const loadStudents = async filter => {
const query = filter ? `?name=${filter}` : ''
const response = await fetch('/students' + query)
return await response.json()
}
const Students = () => {
const [ students, setStudents ] = useState([])
const [ filter, setFilter ] = useState('')
useEffect(() => {
loadStudents(filter).then(setStudents)
}, [ filter ])
return (
<>
<h1 id='students.title'>Students</h1>
<ul aria-labelledby='students.title'>
{students.map(({ name }) => (
<li key={name} aria-labelledby={`students[${name}]`}>
<span id={`students[${name}]`}>{name}</span>
</li>
))}
</ul>
<input
type='text'
placeholder='filter'
value={filter}
onChange={e => setFilter(e.target.value)}
/>
</>
)
}
export default Students
1 The Student
component actually violates the Single Responsibility Principle, and it should be refactored. But this is beyond the scope of this article.
The app
Let’s add the component to our React app so that we can see it in the browser:
// src/App.js
import Students from './Students'
const App = () => <Students />
export default App
Let’s start the app:
npm start
And let’s open the browser at http://localhost:3000
There are no students, and the browser’s console reveals an error:
Uncaught (in promise) SyntaxError: Unexpected token < in JSON at position 0
The problem is that there is no backend responding to our component’s fetch
requests. In our tests, we used mocked responses.
So let’s create a simple fake backend.
Let’s create a static file called students
(without file extension):
touch public/students
And put the following JSON inside:
[ { "name": "John" }, { "name": "Jane" }, { "name": "Jim" } ]
When we now refresh the browser page, we should be able to see some students:
Styling
Now that we can see our component, let’s style it a bit.
Let’s setup Bootstrap by simply adding the stylesheet into the head of the app’s index.html
:
<!-- public/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<!-- ... -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet">
</head>
<!-- ... -->
</html>
Then, let’s make sure that there is no other styling messing around with our Bootstrap styling:
// src/index.js
import React from 'react'
import ReactDOM from 'react-dom'
import App from './App'
ReactDOM.render(
<React.StrictMode>
<App />
</React.StrictMode>,
document.getElementById('root')
)
And finally, let’s use a few classes here and there to make the Students
components look nice:
// src/Students.js
const Students = () => {
// ...
return (
<div className='container'>
<h1 id='students.title'>Students</h1>
<ul aria-labelledby='students.title'>
{/* ... */}
</ul>
<div className='form-floating'>
<input
className='form-control'
// ...
/>
<label htmlFor='filter'>Filter</label>
</div>
</div>
)
}
Et voilà:
Summary
What a ride!
Let’s review what we’ve learned by looking at the answers to the following questions:
When should I use useEffect
?
Here are the most common use cases for useEffect
.
You have a functional component, and you want to…
- run some asynchronous code
- run some code only when the component is rendered for the first time (on mount)
- run some code when a property changes
- run some code when a part of the component’s state changes
How do I use the useEffect
dependency array?
There are essentially three possible cases:
1) No dependency array
useEffect(() => {
doOnEveryRender()
})
If you don’t pass any array as a second argument to useEffect
, your effect will fire on every render.
You will most likely never want that – so don’t forget about the array!
2) Empty dependency array
useEffect(() => {
doOnlyOnceOnMount()
}, [])
This is a common case. The effect only fires once – when the component was mounted.
3) Non-empty dependencies array
useEffect(() => {
doOnMountAndWhenFooOrBarChanges(foo, bar)
}, [ foo, bar ])
If you want the effect to fire when a property or a part of the state changes, mention that in the dependency array.
React will remind you with warnings should you ever use a property in your effect without mentioning it as a dependency.
How do I execute some asynchronous code within useEffect
?
You have three options:
a) Promise.then(..)
useEffect(() => {
getValueAsync(..).then(({ value }) => setValue(value))
}, [])
Calling an asynchronous function within useEffect
and defining what to do when the Promise resolves with .then(..)
is straightforward if you’re familiar with Promises.
b) async/await
useEffect(() => {
const getAndSetValue = async () => {
const { value } = await getValueAsync()
setValue(value)
}
getAndSetValue()
}, [])
If you want to use the newer async/await
syntax, you must be aware of one caveat: The function passed as the first argument to useEffect
cannot be an async
function.
// unfortunately, this is not possible:
useEffect(async () => {
const { value } = await getValueAsync()
setValue(value)
}, [])
You can, however, wrap your async code in an async
helper function and call this function within useEffect
instead.
c) A mix of both worlds
const getValue = async () => {
const { value } = await getValueAsync()
return value
}
const Component = () => {
useEffect(() => {
getValue().then(setValue)
}, [])
return (
// ...
)
}
I follow clean code principles, and I often end up having my asynchronous code in helper functions outside of my React components.
Using Promise.then(..)
within my effect is then often the most elegant way of expressing what I want to accomplish (“get value then set value”).
How do I handle infinite rendering loops?
When your app freezes or your test runs forever, chances are good that you created an infinite rendering loop.
This usually happens when three ingredients are mixed together:
- your render function (a.k.a. your functional component) unconditionally schedules an asynchronous process
- this asynchronous process eventually results in a state change
- this state change causes a rerender of your component
This causes an endless loop of rerenders.
Usually, you have two options to resolve the issue and to prevent the loop:
a) Add the missing dependency array
Check if you’ve forgotten to pass the dependency array as second argument to useEffect
.
useEffect(() => {
doOnEveryRender()
})
// vs.
useEffect(() => {
doOnlyOnceOnMount()
}, [])
Usually, we don’t want to trigger the effect on every render!
b) Add an if
statement
And even if your effect has dependencies, it might not be correct to trigger the effect whenever one of the dependencies changes.
useEffect(() => {
if (!isLoaded)
doOnMountWhenNotLoadedAndWhenIsLoadedChangesToFalse()
}, [ isLoaded ])
Consider using an if
statement to clearly define under which conditions the code should be triggered.
How do I deal with a not wrapped in act(..)
warning?
Not by using act(..)
!
At least not, if you’re using React Testing Library. The warning is misleading.
You have again three options:
a) await findBy*(..)
expect(await screen.findByRole('button', { name: /login/i }))
.toBeInTheDocument()
Use await findBy*(..)
if you expect something DOM-related after some asynchronous code was triggered.
b) await waitFor(..)
await waitFor(
() => expect(fetch).toHaveBeenCalledWith('/students')
)
Use await waitFor(..)
if you’re expecting something that is not DOM-related after some asynchronous code was triggered.
c) await waitForElementToBeRemoved(..)
await waitForElementToBeRemoved(
screen.getByRole('button', { name: /logout/ })
)
Use await waitForElementToBeRemoved(..)
if you expect a DOM element to disappear after some asynchronous code is triggered.
Asynchronous processes usually are started during the initial render phase of a component or after simulating user interaction.
How do I test a useEffect
hook?
Indirectly!
Asking how to test some code that uses useEffect
is like asking how to test this if
statement or this concatenation operation +
.
useEffect
is a tool that we use to solve a specific problem. And we shouldn’t write tests with the aim to cover lines of code.
Instead, we should always test if our code or component can solve a problem.
If we happen to use useEffect
to solve the problem, then we will test useEffect
indirectly.
By the way, the same is true for all React hooks. They are all tested indirectly.
Always test from the user’s perspective. A user clicks, and a user types. And as a result, DOM elements will be displayed, updated, or they disappear.
None of the tests we’ve written today mention useEffect
.
You can, in fact, rewrite the component into a traditional class-based component where lifecycle methods like componentDidMount()
and componentDidUpdate()
take care of what useEffect
does in our functional component. The tests don’t care. All the tests will still pass.
// src/Students.js
import { Component } from 'react'
const loadStudents = async filter => {
const query = filter ? `?name=${filter}` : ''
const response = await fetch('/students' + query)
return await response.json()
}
class Students extends Component {
state = {
students: [],
filter: '',
}
componentDidMount() {
loadStudents(this.state.filter)
.then(students => this.setState({ students }))
}
componentDidUpdate(_, prevState) {
if (this.state.filter !== prevState.filter)
loadStudents(this.state.filter)
.then(students => this.setState({ students }))
}
render() {
return (
<div className='container'>
<h1 id='students.title'>Students</h1>
<ul aria-labelledby='students.title'>
{this.state.students.map(({ name }) => (
<li key={name} aria-labelledby={`students[${name}]`}>
<span id={`students[${name}]`}>{name}</span>
</li>
))}
</ul>
<div className='form-floating'>
<input
type='text'
className='form-control'
placeholder='filter'
value={this.state.filter}
onChange={e => this.setState({ filter: e.target.value })}
/>
<label htmlFor='filter'>Filter</label>
</div>
</div>
)
}
}
export default Students
Notice how the functional component with useEffect
manages to achieve the same result with less code when compared to the class-based component with lifecycle hooks.
And that wraps it up.
Do you have any questions? Just drop me an email (I read all messages).
See you around,
-David