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.
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
- An Effect synchronises with an external system
- The dependency array, and why the linter is right
- Cleanup: timers, subscriptions, AbortController
- StrictMode and the double run in development
- Fetching data: races, and when to reach for a library
- You might not need an Effect: derived data and event handlers
- A 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.
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-eslintThe 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
aorbchanged
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.
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,
])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.
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>
}- The external system here is the browser's interval. The Effect starts it and the cleanup stops it.
- The dependency is
expired, notleft. Withleft, the interval would be recreated every second. Inside the interval we use the updatersetLeft((s) => s - 1), so the Effect doesn't need to read the currentleft. - When time runs out,
expiredbecomestrue: React runs the old Effect's cleanup, and the new run returns straight away, so no intervals remain. The test checks this withvi.getTimerCount(). - Following the docs' convention,
initialSecondshas aninitialprefix: 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
removeEventListenerwith 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,
destroyit 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.
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>,
)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:
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
})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:
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>
)
}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.
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[]
}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 callsabort(). When the query changes, React first cleans up the previous Effect: the stalefetchis cancelled and rejects with anAbortError, 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
setStatesynchronously 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 = falseflag that the cleanup sets totrue, so the response is ignored.AbortControlleradditionally 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:
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
}$ 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)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.
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
}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-depswarnings 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.
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')
})
})$ 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)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.