• Skip to primary navigation
  • Skip to main content

Test-first

Master the art of Test-driven Development (TDD)

  • Home
  • Newsletter
  • Blog

useEffect – How to test React Effect Hooks

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:

  • How to indirectly test a useEffect hook

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:

Students App showing no students

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:  [
  • John
  • ]

    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

    Students App showing no students

    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:

    Unstyled Students App

    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à:

    Students App showing no students

    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:

    1. your render function (a.k.a. your functional component) unconditionally schedules an asynchronous process
    2. this asynchronous process eventually results in a state change
    3. 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

    Get David’s best advice on TDD straight to your inbox

    Tap into a senior software developer’s brain with 25 years of industry-relevant experience that successfully uses Test-driven Development daily.

    I’ll send you a few emails per month to keep you posted. Of course, you can opt out at any time.
    By subscribing, you accept my privacy policy. I promise I won’t spam you or sell your data.

    Connect

    • Email
    • LinkedIn
    • Phone
    • YouTube

    Discover

    www.cultivate.software

    Legal

    Imprint  ·  Privacy Policy  ·  Cookie Policy

    Copyright © 2025 · cultivate GmbH