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 fetchWhat it looks likeFix
Race between two requestsOld response overwrites the new oneAbort on cleanup or guard with a flag
No cachingSame data refetched on every visitA query library or framework-level fetch
Repeated state boilerplateloading/error/data triad in every componentEncapsulate in a hook or library
No retry or deduplicationDouble requests under StrictModeDedupe by key in the query library
Waterfall on mountChild waits for parent, then fetchesFetch 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.

State with hooks Data fetching and server components

Last refreshed 2026-09-18.