How to buildOctober 11, 20269 min read
ReactStep 4 of 8

useEffect: synchronising with the outside world, and when you don't need it

In short

useEffect synchronises a component with a system outside React, such as a timer, a subscription, the network or a browser API: the Effect's code runs after React has updated the screen, and its cleanup function undoes it before the next run and when the component is removed. You don't choose dependencies: every prop, state value and component variable the Effect reads goes into the array, and the linter checks this. If there's no external system, because the data can be calculated during render or the action is triggered by a click, you don't need an Effect.

Deep diveLevel: IntermediateReact Hooks

After this you will be able to

  • Write an Effect with correct dependencies and a cleanup function for a timer and for a request with AbortController
  • Explain StrictMode's double run of Effects and check that an Effect survives it
  • Find and fix a request race when fetching data, and verify it with a fake-timer test
  • Replace needless Effects with a calculation during render, an event handler or a key

Verified with

React 19.3.0TypeScript 6.0.3TypeScript 7.0.2Vite 8.3.4Vitest 5.0.3@testing-library/react 16.3.3ESLint 10.12.0eslint-plugin-react-hooks 7.1.1Node.js 24 LTS (24.18.0)

Checked against docs: October 11, 2026

Contents
  1. 01An Effect synchronises with an external system
  2. 02The dependency array, and why the linter is right
  3. 03Cleanup: timers, subscriptions, AbortController
  4. 04StrictMode and the double run in development
  5. 05Fetching data: races, and when to reach for a library
  6. 06You might not need an Effect: derived data and event handlers
  7. 07A checklist before useEffect, tests and practice

An Effect synchronises with an external system

Code in a React component lives in three places. Rendering only calculates markup from props and state. Event handlers do something in response to a specific user action: a click, typing, submitting a form. Effects are the third place: the docs describe them as code that runs after rendering so that you can synchronise your component with a system outside React.

Component body (render)

When it runs
on every render; must be pure
Example
calculate the cart total, filter a list

Event handler

When it runs
in response to a specific action
Example
send a booking request when “Book” is clicked

Effect

When it runs
after React has updated the screen, because the component is displayed
Example
start a hold timer, subscribe to events, load data for the current query

The React docs call Effects an “escape hatch”: you use them when you need to step outside React and there's no built-in solution. That leads to the key question of this lesson: is there an external system here? If not, the Effect is almost certainly unnecessary.

This lesson continues the track: after useState, we'll build a beauty salon's booking hold timer and a service search that loads from a server, test them, and go through four Effects worth deleting. You'll need state and useState, plus Promise and async/await.

bash
npm create vite@latest booking-search -- --template react-ts
cd booking-search
npm install
npm install -D vitest jsdom @testing-library/react @testing-library/dom
npm install -D eslint eslint-plugin-react-hooks typescript-eslint
The Vite template uses the oxlint linter; for React's Hooks rules we added ESLint with the official plugin.

The dependency array, and why the linter is right

The second argument to useEffect is the dependency array. React compares each dependency with its previous value using Object.is and re-runs the Effect only if something changed.

useEffect(fn)

When the Effect runs
after every render

useEffect(fn, [])

When the Effect runs
once, when the component appears (in development under StrictMode, with one extra cycle; see below)

useEffect(fn, [a, b])

When the Effect runs
when the component appears and after renders in which a or b changed

The main rule from the docs: you don't choose your dependencies. Reactive values (props, state and every variable declared in the component body) can change on the next render. If the Effect reads them, they must be in the array; otherwise the Effect keeps working with stale values. The exhaustive-deps rule from eslint-plugin-react-hooks checks this, and the docs say plainly that all of its errors are legitimate and that you shouldn't suppress the linter.

eslint.config.js
import reactHooks from 'eslint-plugin-react-hooks'
import { defineConfig } from 'eslint/config'
import tseslint from 'typescript-eslint'

export default defineConfig([
  {
    files: ['src/**/*.{ts,tsx}'],
    languageOptions: { parser: tseslint.parser },
  },
  reactHooks.configs.flat.recommended,
])
The plugin's recommended rule set; the typescript-eslint parser lets ESLint read TSX.

If an Effect shouldn't re-run when a value changes, you have to prove the value isn't reactive: move the constant outside the component or declare it inside the Effect itself. A separate trap is objects and functions created in the component body: they're new values on every render, so an Effect that depends on them re-runs more often than needed. The usual fix is to move them inside the Effect.

Cleanup: timers, subscriptions, AbortController

An Effect can return a cleanup function. React calls it before every re-run of the Effect and one final time when the component is removed from the page. Cleanup mirrors the Effect: started an interval, stop it; subscribed, unsubscribe; sent a request, cancel it.

src/components/HoldTimer.tsx
import { useEffect, useState } from 'react'

function format(totalSeconds: number) {
  const minutes = Math.floor(totalSeconds / 60)
  const seconds = String(totalSeconds % 60).padStart(2, '0')
  return `${minutes}:${seconds}`
}

export function HoldTimer({ initialSeconds }: { initialSeconds: number }) {
  const [left, setLeft] = useState(initialSeconds)
  const expired = left <= 0

  useEffect(() => {
    if (expired) return // nothing to synchronise: the timer is no longer needed

    const id = setInterval(() => {
      setLeft((s) => s - 1)
    }, 1000)

    return () => clearInterval(id) // cleanup: on unmount and before the next run
  }, [expired])

  return <p role="status">{expired ? 'Hold expired' : `Slot held for ${format(left)}`}</p>
}
A booking hold timer: the slot is held while the client completes the booking.
  • The external system here is the browser's interval. The Effect starts it and the cleanup stops it.
  • The dependency is expired, not left. With left, the interval would be recreated every second. Inside the interval we use the updater setLeft((s) => s - 1), so the Effect doesn't need to read the current left.
  • When time runs out, expired becomes true: React runs the old Effect's cleanup, and the new run returns straight away, so no intervals remain. The test checks this with vi.getTimerCount().
  • Following the docs' convention, initialSeconds has an initial prefix: the prop sets only the starting value, and later changes to it are ignored.

setInterval, setTimeout

What the cleanup does
clearInterval, clearTimeout

addEventListener

What the cleanup does
removeEventListener with the same function

A chat connection, a WebSocket

What the cleanup does
disconnect, close

fetch with an AbortController

What the cleanup does
controller.abort(), so the response to a stale request never lands

An animation, a third-party widget

What the cleanup does
stop it, destroy it or reset it

StrictMode and the double run in development

The Vite template wraps the app in StrictMode. According to the docs, in development it adds extra checks: components re-render an extra time, Effects re-run an extra time, and deprecated APIs are flagged. All of these checks are development-only and don't affect the production build.

src/main.tsx
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)
Unchanged from the template.

For Effects, this means React mounts the component, immediately unmounts it and mounts it again. You see setup → cleanup → setup. That's not a bug, it's a check: if an Effect lacks proper cleanup, the double run exposes it. The test confirms how our timer behaves:

src/effects.test.tsx
  it('in StrictMode during development: setup → cleanup → setup', () => {
    const setSpy = vi.spyOn(globalThis, 'setInterval')
    const clearSpy = vi.spyOn(globalThis, 'clearInterval')
    render(
      <StrictMode>
        <HoldTimer initialSeconds={60} />
      </StrictMode>,
    )
    expect(setSpy).toHaveBeenCalledTimes(2)
    expect(clearSpy).toHaveBeenCalledTimes(1)
    expect(vi.getTimerCount()).toBe(1) // exactly one interval ends up running
  })
A fragment of the test file; the full file is in the last section.

The docs state the rule: the user shouldn't be able to tell an Effect running once (as in production) from a setup → cleanup → setup sequence (as in development). So the right question isn't “how do I run the Effect once” but “how do I fix the Effect so it works after remounting”. The answer is almost always to add cleanup.

Fetching data: races, and when to reach for a library

Service search is a legitimate case for an Effect: the results have to stay in sync with the current query text, however it changed. Let's start with the naive version you'll see most often:

src/bad/NaiveSearch.tsx
import { useEffect, useState } from 'react'
import { searchServices, type Service } from '../api/services.ts'

export function NaiveSearch() {
  const [query, setQuery] = useState('')
  const [services, setServices] = useState<Service[]>([])

  useEffect(() => {
    if (query === '') return
    // 🔴 no cleanup: whichever response arrives last wins
    searchServices(query, new AbortController().signal).then(setServices)
  }, [query])

  return (
    <section>
      <label>
        Search services <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      <ul>
        {services.map((service) => (
          <li key={service.id}>{service.title}</li>
        ))}
      </ul>
    </section>
  )
}
Don't do this: there's no cleanup.

The user types “ma”, then “manicure”. Two requests go out, and the responses can arrive in any order. In the test we simulated a slow first response (500 ms) and a fast second one (100 ms): once both arrived, the screen showed the list for “ma” even though the field said “manicure”. That's a race condition, and the linter can't see it because the code is technically valid.

src/api/services.ts
export interface Service {
  id: string
  title: string
}

export async function searchServices(query: string, signal: AbortSignal): Promise<Service[]> {
  const response = await fetch(`/api/services?q=${encodeURIComponent(query)}`, { signal })
  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`)
  }
  return (await response.json()) as Service[]
}
src/components/ServiceSearch.tsx
import { useEffect, useState } from 'react'
import { searchServices, type Service } from '../api/services.ts'

type Result =
  | { query: string; services: Service[] }
  | { query: string; error: string }

export function ServiceSearch() {
  const [query, setQuery] = useState('')
  const [result, setResult] = useState<Result | null>(null)

  useEffect(() => {
    if (query === '') return

    const controller = new AbortController()

    searchServices(query, controller.signal)
      .then((services) => setResult({ query, services }))
      .catch(() => {
        if (controller.signal.aborted) return // we cancelled the request ourselves, so it's not an error
        setResult({ query, error: 'Couldn’t load services' })
      })

    return () => controller.abort() // the stale request is cancelled
  }, [query])

  // “loading” isn't stored in state: it means the result isn't for the current query
  const isLoading = query !== '' && result?.query !== query
  const current = result?.query === query ? result : null

  return (
    <section>
      <label>
        Search services <input value={query} onChange={(e) => setQuery(e.target.value)} />
      </label>
      {query === '' && <p>Start typing a service name</p>}
      {isLoading && <p>Searching…</p>}
      {current && 'error' in current && <p role="alert">{current.error}</p>}
      {current && 'services' in current && (
        <ul>
          {current.services.map((service) => (
            <li key={service.id}>{service.title}</li>
          ))}
        </ul>
      )}
    </section>
  )
}
  • Each run of the Effect creates its own AbortController, and the cleanup calls abort(). When the query changes, React first cleans up the previous Effect: the stale fetch is cancelled and rejects with an AbortError, which we don't display.
  • State holds the result together with the query it belongs to. There's no loading flag: “loading” simply means the latest result isn't for the current query. That way we never call setState synchronously in the Effect body, and the UI can't show someone else's response.
  • The React docs also show a simpler cleanup: a let ignore = false flag that the cleanup sets to true, so the response is ignored. AbortController additionally cancels the network request itself.

Fetching data in Effects has limitations, which the docs list directly: Effects don't run on the server, it's easy to create “network waterfalls”, there's no preloading or caching, and there's a lot of repetitive code. The official recommendation: in a framework, use its built-in data fetching; without one, use a client-side cache such as TanStack Query or SWR. A hand-written Effect like the one in this lesson is still worth understanding: it's what those libraries do under the hood.

You might not need an Effect: derived data and event handlers

The docs highlight two common cases where you don't need an Effect: transforming data for rendering and handling user events. Here are four Effects that keep turning up in real code:

src/bad/Unneeded.tsx
import { useEffect, useState } from 'react'
import type { Service } from '../api/services.ts'

// 1. Derived data via an Effect
export function FilteredList({ services, filter }: { services: Service[]; filter: string }) {
  const [visible, setVisible] = useState<Service[]>([])
  useEffect(() => {
    setVisible(services.filter((s) => s.title.includes(filter)))
  }, [services, filter])
  return <p>Found: {visible.length}</p>
}

// 2. A user action via an Effect
export function BookButton({ serviceId }: { serviceId: string }) {
  const [booked, setBooked] = useState(false)
  useEffect(() => {
    if (booked) {
      fetch('/api/bookings', { method: 'POST', body: JSON.stringify({ serviceId }) })
    }
  }, [booked, serviceId])
  return <button onClick={() => setBooked(true)}>Book</button>
}

// 3. Resetting state on a prop change via an Effect
export function ReviewForm({ serviceId }: { serviceId: string }) {
  const [text, setText] = useState('')
  useEffect(() => {
    setText('')
  }, [serviceId])
  return <textarea value={text} onChange={(e) => setText(e.target.value)} />
}

// 4. A missing dependency
export function PageTitle({ name }: { name: string }) {
  useEffect(() => {
    document.title = `Booking: ${name}`
  }, [])
  return null
}
terminal
$ npx eslint src/bad/Unneeded.tsx

src/bad/Unneeded.tsx
   8:5  error    Error: Calling setState synchronously within an effect can trigger cascading renders
…
>  8 |     setVisible(services.filter((s) => s.title.includes(filter)))
     |     ^^^^^^^^^^ Avoid calling setState() directly within an effect
…  react-hooks/set-state-in-effect
  28:5  error    Error: Calling setState synchronously within an effect can trigger cascading renders
…
> 28 |     setText('')
     |     ^^^^^^^ Avoid calling setState() directly within an effect
…  react-hooks/set-state-in-effect
  37:6  warning  React Hook useEffect has a missing dependency: 'name'. Either include it or remove the dependency array  react-hooks/exhaustive-deps

✖ 3 problems (2 errors, 1 warning)
ESLint output shortened: the rule explanations and paths are omitted.

The linter caught three cases out of four. The set-state-in-effect rule from the recommended set forbids calling setState synchronously in an Effect body, because it causes a cascade of extra renders. BookButton, however, is technically valid, and no linter will tell you the request belongs in an event handler. The docs suggest asking why the code needs to run: if it's because the component was displayed, it's an Effect; if it's because the user clicked a button, it's an event handler.

src/good/Fixed.tsx
import { useEffect, useState } from 'react'
import type { Service } from '../api/services.ts'

// 1. Derived data: calculate it during render
export function FilteredList({ services, filter }: { services: Service[]; filter: string }) {
  const visible = services.filter((s) => s.title.includes(filter))
  return <p>Found: {visible.length}</p>
}

// 2. A user action: handle it in the event handler
export function BookButton({ serviceId }: { serviceId: string }) {
  const [booked, setBooked] = useState(false)

  function handleClick() {
    setBooked(true)
    fetch('/api/bookings', { method: 'POST', body: JSON.stringify({ serviceId }) })
  }

  return (
    <button onClick={handleClick} disabled={booked}>
      {booked ? 'Request sent' : 'Book'}
    </button>
  )
}

// 3. Resetting state: use a key in the parent
export function ReviewForm() {
  const [text, setText] = useState('')
  return (
    <textarea aria-label="Review" value={text} onChange={(e) => setText(e.target.value)} />
  )
}

export function ReviewSection({ serviceId }: { serviceId: string }) {
  return <ReviewForm key={serviceId} />
}

// 4. Every reactive value is a dependency
export function PageTitle({ name }: { name: string }) {
  useEffect(() => {
    document.title = `Booking: ${name}`
  }, [name])
  return null
}
The fixed versions. For brevity, BookButton doesn't handle request errors.

setVisible(filter(...)) in an Effect

Replace with
a calculation during render: const visible = …
Why
one render instead of two; cache expensive calculations with useMemo

A request after the booked flag changes

Replace with
the request in the click handler
Why
it's a user action, not synchronisation; the test confirms one request per click, even under StrictMode

setText('') when serviceId changes

Replace with
key={serviceId} on the component
Why
React treats components with different keys as different and resets all their state

[] while reading name

Replace with
[name]
Why
every reactive value is a dependency

A checklist before useEffect, tests and practice

  • Is there an external system: a timer, a subscription, the network, DOM outside React, a third-party widget? If not, you don't need an Effect.
  • Can the value be calculated from props and state during render? Then it's a variable, not state plus an Effect.
  • Does the code run because the user did something? Then it belongs in an event handler.
  • Need to reset all state when a prop changes? Give the component a different key.
  • What does the cleanup undo? Every run needs its own start/stop pair.
  • Are all reactive values in the dependencies? Fix exhaustive-deps warnings rather than suppressing them.
  • For data fetching: is the stale request cancelled? Would your framework's mechanism or a caching library be better?
  • Does the Effect still work after setup → cleanup → setup in StrictMode?

The tests pin down the behaviour we've discussed: the timer counting down and stopping, cleanup on unmount, the double run under StrictMode, cancelling the stale request and handling a server error. fetch is replaced with a fake that responds after a set delay and honours AbortSignal, and time is controlled with Vitest's fake timers.

src/effects.test.tsx
import { act, fireEvent, render, screen } from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { HoldTimer } from './components/HoldTimer.tsx'
import { ServiceSearch } from './components/ServiceSearch.tsx'

beforeEach(() => {
  vi.useFakeTimers()
})

afterEach(() => {
  vi.useRealTimers()
  vi.unstubAllGlobals()
  vi.restoreAllMocks()
})

describe('HoldTimer', () => {
  it('counts down and stops the interval once the hold expires', () => {
    render(<HoldTimer initialSeconds={3} />)
    expect(screen.getByRole('status').textContent).toBe('Slot held for 0:03')

    act(() => vi.advanceTimersByTime(1000))
    expect(screen.getByRole('status').textContent).toBe('Slot held for 0:02')

    act(() => vi.advanceTimersByTime(2000))
    expect(screen.getByRole('status').textContent).toBe('Hold expired')
    expect(vi.getTimerCount()).toBe(0) // cleanup ran, no intervals left
  })

  it('clears the interval on unmount', () => {
    const { unmount } = render(<HoldTimer initialSeconds={60} />)
    expect(vi.getTimerCount()).toBe(1)
    unmount()
    expect(vi.getTimerCount()).toBe(0)
  })

  it('in StrictMode during development: setup → cleanup → setup', () => {
    const setSpy = vi.spyOn(globalThis, 'setInterval')
    const clearSpy = vi.spyOn(globalThis, 'clearInterval')
    render(
      <StrictMode>
        <HoldTimer initialSeconds={60} />
      </StrictMode>,
    )
    expect(setSpy).toHaveBeenCalledTimes(2)
    expect(clearSpy).toHaveBeenCalledTimes(1)
    expect(vi.getTimerCount()).toBe(1) // exactly one interval ends up running
  })
})

/* a fake fetch: responds after a delay and honours AbortSignal */
function stubFetch(delays: Record<string, number>, signals: AbortSignal[]) {
  vi.stubGlobal(
    'fetch',
    vi.fn((url: string, init: RequestInit) => {
      const q = new URL(url, 'http://localhost').searchParams.get('q') ?? ''
      signals.push(init.signal!)
      return new Promise<Response>((resolve, reject) => {
        const timer = setTimeout(() => {
          const body = JSON.stringify([{ id: q, title: `Result for “${q}”` }])
          resolve(new Response(body, { status: 200 }))
        }, delays[q] ?? 0)
        init.signal!.addEventListener('abort', () => {
          clearTimeout(timer)
          reject(new DOMException('Aborted', 'AbortError'))
        })
      })
    }),
  )
}

describe('ServiceSearch', () => {
  it('shows the response to the latest query and cancels the stale one', async () => {
    const signals: AbortSignal[] = []
    stubFetch({ ma: 500, manicure: 100 }, signals) // the first request is slower than the second
    render(<ServiceSearch />)
    const input = screen.getByLabelText('Search services')

    fireEvent.change(input, { target: { value: 'ma' } })
    fireEvent.change(input, { target: { value: 'manicure' } })
    expect(screen.getByText('Searching…')).toBeTruthy()

    await act(() => vi.advanceTimersByTimeAsync(600))

    expect(signals[0].aborted).toBe(true)
    expect(signals[1].aborted).toBe(false)
    expect(screen.getAllByRole('listitem').map((li) => li.textContent)).toEqual([
      'Result for “manicure”',
    ])
    expect(screen.queryByText('Searching…')).toBeNull()
  })

  it('shows an error if the server responds with a non-2xx status', async () => {
    vi.stubGlobal('fetch', vi.fn(async () => new Response('[]', { status: 500 })))
    render(<ServiceSearch />)
    fireEvent.change(screen.getByLabelText('Search services'), { target: { value: 'haircut' } })
    await act(() => vi.advanceTimersByTimeAsync(0))
    expect(screen.getByRole('alert').textContent).toBe('Couldn’t load services')
  })
})
terminal
$ npm test

> booking-search@0.0.0 test
> vitest run

 RUN  v5.0.3 …/booking-search

 Test Files  3 passed (3)
      Tests  10 passed (10)
Three files: the main tests, the race demonstration for the naive version and the tests for the fixed examples.

Effects and state are the foundation of almost every interactive component. Next on the React track: forms and validation, where the controlled inputs from the previous lesson meet real requirements, followed by state management in larger apps and front-end testing.

How this was verified7 · passedchecked 11 October 2026

“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.

Tell us about your task — we'll review your request and get back to you

Got a project?
We'll scope and estimate it — for free.

Tell us about the product or the problem. After reviewing the task we'll come back with an architecture, a timeline and a price for the first version.

  • Scope and risks estimated before we start
  • NDA — yours or ours
  • Work starts within a week

Please do not send passwords, access keys or source code through this form — we will agree a safe way to share access separately.

By clicking the button you agree to the processing of your personal data.