Effects and data fetching
useEffect dependencies and cleanup, why fetching in an effect has sharp edges, and what production code does instead.
Effects and their dependencies
import { useState, useEffect } from 'react'
function Clock() {
const [now, setNow] = useState(() => new Date())
useEffect(() => {
const id = setInterval(() => setNow(new Date()), 1000)
return () => clearInterval(id) // cleanup runs on unmount and before re-running
}, []) // empty array: set up once
return <time>{now.toLocaleTimeString()}</time>
}- The dependency array lists every reactive value the effect reads. Omitting one creates a stale closure that keeps using an old value.
- An empty array means "no dependencies" — the effect runs after the first render and cleans up on unmount.
- No array at all means the effect runs after every render, which is almost never what you want.
- Always return a cleanup when the effect subscribes, listens, starts a timer, or kicks off a request that could still be in flight.
- In development, StrictMode runs mount, unmount and mount again on purpose, so a missing cleanup shows up as a doubled subscription or a duplicated log line.
useEffect(() => {
const controller = new AbortController()
fetch(url, { signal: controller.signal })
.then(r => r.json())
.then(setData)
.catch(e => { if (e.name !== 'AbortError') setError(e) })
return () => controller.abort()
}, [url])The fetching-in-an-effect trap
function Guide({ slug }) {
const [state, setState] = useState({ loading: true, data: null, error: null })
useEffect(() => {
let cancelled = false
setState({ loading: true, data: null, error: null })
fetch('/api/guides/' + slug)
.then(r => (r.ok ? r.json() : Promise.reject(new Error('HTTP ' + r.status))))
.then(data => { if (!cancelled) setState({ loading: false, data, error: null }) })
.catch(error => { if (!cancelled) setState({ loading: false, data: null, error }) })
return () => { cancelled = true }
}, [slug])
if (state.loading) return <Spinner />
if (state.error) return <ErrorBox error={state.error} />
return <Article data={state.data} />
}| Problem in a raw effect fetch | What it looks like | Fix |
|---|---|---|
| Race between two requests | Old response overwrites the new one | Abort on cleanup or guard with a flag |
| No caching | Same data refetched on every visit | A query library or framework-level fetch |
| Repeated state boilerplate | loading/error/data triad in every component | Encapsulate in a hook or library |
| No retry or deduplication | Double requests under StrictMode | Dedupe by key in the query library |
| Waterfall on mount | Child waits for parent, then fetches | Fetch in parallel, or fetch on the server |
⚠️
An effect is for synchronising with something outside React — a socket, a timer, a third-party widget. Using it as a data layer works for a demo and becomes the source of races, waterfalls and duplicate requests in production; move server state into a query library or fetch on the server.
What production code does instead
// a query library owns cache, retries, deduplication and refetching
const { data, isPending, error } = useQuery({
queryKey: ['guide', slug],
queryFn: ({ signal }) => fetchGuide(slug, signal),
staleTime: 60_000
})
// mutations declare what becomes stale afterwards
const publish = useMutation({
mutationFn: (id) => fetch('/api/publish/' + id, { method: 'POST' }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['guides'] })
})- Keep server state (cached, shared, refetchable) apart from UI state (hover, draft text, open panel). They have different lifetimes and belong in different tools.
- A query key is the cache identity:
['guide', slug]and['guides']are different entries, and invalidating the list after a mutation is what keeps the UI consistent. - Prefer a framework that fetches on the server (Next.js, Remix) when the page is content-heavy: no loading spinner on first paint and no request waterfall.
- Only reach for a global store for client state that many unrelated components share.
// a custom hook keeps components free of request details
function useGuide(slug) {
const [data, setData] = useState(null)
const [error, setError] = useState(null)
useEffect(() => {
const controller = new AbortController()
fetchGuide(slug, controller.signal).then(setData).catch(setError)
return () => controller.abort()
}, [slug])
return { data, error }
}FAQ
Why does my effect run twice on mount?
StrictMode in development mounts, unmounts and remounts to surface missing cleanups. Fix the cleanup rather than removing StrictMode — the bug is real and would appear in production with concurrent rendering.
Can an effect be async?
Not directly — an async function returns a promise, which useEffect would treat as a cleanup function. Declare the async function inside the effect and call it, or abort from the effect's own cleanup.
Related
State with hooks Data fetching and server components
Last refreshed 2026-09-18.