There was a point in my React journey where useEffect
was my answer to everything.
Data needs to load when the component mounts? useEffect.
Something needs to happen when a prop changes? useEffect.
Need to sync two pieces of state? useEffect.
Six months ago I looked at a component I'd written and
counted seven useEffect calls. The component worked.
It was also completely impossible to follow. Bugs were
showing up that I couldn't reproduce consistently and
fixing one would break another.
That component forced me to actually understand what
useEffect is for — and more importantly, what it isn't for.
What useEffect is actually for
useEffect exists for one purpose: synchronizing your
component with something outside of React.
That's it. Outside of React means:
- A browser API (document.title, addEventListener)
- A third party library that doesn't know about React
- A network request
- A WebSocket connection
- An interval or timeout
If what you're doing doesn't involve something outside
of React, useEffect is probably the wrong tool.
The mistake I made most often
Syncing state with state.
// What I used to write
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
const [fullName, setFullName] = useState('')
useEffect(() => {
setFullName(`${firstName} ${lastName}`)
}, [firstName, lastName])
This looks reasonable. It's actually creating a problem.
Every time firstName or lastName changes, React renders
the component, then runs the effect, then sets fullName,
then renders the component again. Two renders for every
one change.
The fix is just a variable:
const [firstName, setFirstName] = useState('')
const [lastName, setLastName] = useState('')
// Calculated during render — no effect needed
const fullName = `${firstName} ${lastName}`
React calculates this on every render anyway. You're not
saving any work by putting it in state — you're adding work.
Fetching data — where it gets complicated
Data fetching is the useEffect use case everyone learns first:
useEffect(() => {
fetch('/api/user')
.then(res => res.json())
.then(data => setUser(data))
}, [])
This works. It also has several problems that don't show
up until your app gets bigger:
No loading state. The component renders before the
data arrives. If you're not handling that, you get
undefined errors or blank flashes.
No error handling. If the fetch fails, nothing happens.
The user sees nothing and doesn't know why.
No cleanup. If the component unmounts before the fetch
completes, you're trying to set state on an unmounted
component.
Race conditions. If the user triggers two fetches
quickly (navigating back and forth), you can't guarantee
which one resolves last. The slower one might overwrite
the faster one.
A more complete version:
useEffect(() => {
let cancelled = false
setLoading(true)
setError(null)
fetch('/api/user')
.then(res => {
if (!res.ok) throw new Error('Request failed')
return res.json()
})
.then(data => {
if (!cancelled) setUser(data)
})
.catch(err => {
if (!cancelled) setError(err.message)
})
.finally(() => {
if (!cancelled) setLoading(false)
})
return () => { cancelled = true }
}, [])
This handles all four problems. It's also 25 lines for
what should be a simple data fetch.
This is why libraries like React Query and SWR exist.
They handle all of this — loading state, error state,
cancellation, deduplication, caching — so you don't
have to write it every time.
I'm not saying never write your own fetch logic. I'm saying
understand what you're giving up when you simplify it.
Event listeners — a genuine useEffect use case
This is wher
e useEffect actually belongs:
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeModal()
}
document.addEventListener('keydown', handleKeyDown)
return () => {
document.removeEventListener('keydown', handleKeyDown)
}
}, [closeModal])
You're synchronizing React with a browser API. There's no
React-native way to listen to keyboard events. useEffect
is exactly right here.
The cleanup function matters. Without it, every time this
component mounts you add a new listener and never remove
the old one. After ten mounts you have ten listeners all
calling closeModal at the same time.
What I use instead now
For derived state: just calculate it during render.
No hook needed.
For expensive calculations: useMemo, but only when
profiling shows it's actually slow. Not as a default.
// Only if the calculation is genuinely expensive
const sortedItems = useMemo(() => {
return items.sort((a, b) => a.name.localeCompare(b.name))
}, [items])
For data fetching: React Query in any app that fetches
from an API. The caching alone is worth it.
For browser APIs: useEffect with proper cleanup.
For syncing with external libraries: useEffect,
but isolate it in a custom hook so the component
code stays clean.
// Custom hook isolates the external sync
const useDocumentTitle = (title: string) => {
useEffect(() => {
const original = document.title
document.title = title
return () => { document.title = original }
}, [title])
}
// Component stays clean
const ProductPage = ({ product }: Props) => {
useDocumentTitle(product.name)
return <div>...</div>
}
The question I ask before every useEffect
Before I write useEffect now I ask: am I synchronizing
with something outside of React?
If the answer is no, I look for a different approach first.
Derived value, event handler, library, custom hook.
useEffect isn't bad. It's a precise tool for a specific
job. Using it for everything is like using a screwdriver
to hammer a nail — it works sometimes, but it's not
what it's for.
That seven-useEffect component I mentioned at the start?
I refactored it to two. One for a WebSocket connection,
one for a document title update.
Everything else moved into derived values, a custom hook,
and React Query.
It's been three months and I haven't touched it since.

Top comments (0)