React

useEffect Mistakes I Stopped Making

Common useEffect patterns that seem correct but cause bugs: stale closures, missing dependencies, derived state in effects, and effects that should be e...

What is useEffect?

useEffect is a React hook that lets you synchronise a component with an external system. It runs after the component renders and accepts two arguments: a setup function and an optional dependency array.

useEffect(() => {
    // runs after render

    return () => {
        // optional cleanup, runs before next effect or on unmount
    }
}, [dependency]) // re-runs when dependency changes

Leave the dependency array out entirely and it runs after every render. Pass an empty array and it runs once on mount. Add values and it re-runs whenever those values change.

Its intended use is for things outside React's control: browser APIs, subscriptions, timers, third-party libraries. It is not a general-purpose tool for reacting to state changes or computing derived values.


I spent a long time writing useEffect code that looked fine but caused subtle bugs. The linter was complaining, I was suppressing warnings, and things were breaking in ways I couldn't reproduce consistently. Most of it came down to the same handful of mistakes repeated in different shapes.

Here are the ones I actually stopped making, and what I do instead.

Stale closures from missing dependencies

This one gets almost everyone early on. You write a handler inside a component, reference it in an effect, and then leave dependencies out or suppress the lint warning because adding them seems to cause an infinite loop.

// Wrong
function SearchBox() {
    const [query] = useState('')

    const search = () => {
        fetch(`/api/search?q=${query}`)
    }

    useEffect(() => {
        const id = setInterval(search, 1000)
        return () => clearInterval(id)
    }, [])
}

The problem: search closes over query at render time. When query changes, the interval still calls the old version of search with the stale value. You're seeing results for the initial query forever.

The simplest fix is to define the function inside the effect itself. You can also use useCallback with a proper dependency list, but moving it inside is usually cleaner:

// Fixed
function SearchBox() {
    const [query, setQuery] = useState('')

    useEffect(() => {
        const search = () => {
            fetch(`/api/search?q=${query}`)
        }

        const id = setInterval(search, 1000)
        return () => clearInterval(id)
    }, [query])
}

Now the effect re-runs when query changes, and search always closes over the current value.

Deriving state inside an effect

I used to reach for useEffect any time I needed to compute something from props or state. It feels natural: "when items changes, update filteredItems." But it's wrong, and it causes a double render every time.

// Wrong
function List({ items }: { items: string[] }) {
    const [filter] = useState('')
    const [filteredItems, setFilteredItems] = useState(items)

    useEffect(() => {
        setFilteredItems(items.filter((i) => i.includes(filter)))
    }, [items, filter])

    return <ul>{filteredItems.map((i) => <li key={i}>{i}</li>)}</ul>
}

React renders once with the old filteredItems, then runs the effect, then renders again with the new value. The UI flickers, and you've added state that doesn't need to exist.

Derived values belong in the render body:

// Fixed
function List({ items }: { items: string[] }) {
    const [filter, setFilter] = useState('')
    const filteredItems = items.filter((i) => i.includes(filter))

    return <ul>{filteredItems.map((i) => <li key={i}>{i}</li>)}</ul>
}

If the computation is expensive, wrap it in useMemo. But don't reach for that by default either. Compute inline first, optimise later if you actually measure a problem.

Resetting state with an effect when a key works

Another pattern I kept writing: reset form state when a prop changes.

// Wrong
function UserForm({ userId }: { userId: string }) {
    const [name, setName] = useState('')

    useEffect(() => {
        setName('')
    }, [userId])

    return <input value={name} onChange={(e) => setName(e.target.value)} />
}

This works but it's noisy. React renders with the old name, runs the effect, sets it to '', then renders again. The component is also holding state that is semantically tied to userId, but the connection is implicit and easy to break.

The right tool here is the key prop. Give the component a key that changes when userId changes, and React replaces the entire instance:

// Fixed
function UserForm({ userId }: { userId: string }) {
    const [name, setName] = useState('')

    return <input value={name} onChange={(e) => setName(e.target.value)} />
}

// At the call site:
<UserForm key={userId} userId={userId} />

No effect, no double render, no stale state risk.

Using an effect for what should be an event handler

This was probably my most common mistake. Something should happen in response to a user action, and I'd wire it up through state and an effect instead of just putting it in the handler.

// Wrong
function CheckoutButton({ cartId }: { cartId: string }) {
    const [submitted, setSubmitted] = useState(false)

    useEffect(() => {
        if (submitted) {
            fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ cartId }) })
            setSubmitted(false)
        }
    }, [submitted, cartId])

    return <button onClick={() => setSubmitted(true)}>Buy now</button>
}

There's no reason for submitted to exist. The effect fires after the render caused by setSubmitted(true), which means you have an extra render, an extra state variable, and code that's harder to read.

// Fixed
function CheckoutButton({ cartId }: { cartId: string }) {
    const handleClick = () => {
        fetch('/api/checkout', { method: 'POST', body: JSON.stringify({ cartId }) })
    }

    return <button onClick={handleClick}>Buy now</button>
}

The rule I follow now: if something happens because of a user action, put the code in the event handler. Effects are for synchronising with something external (subscriptions, timers, browser APIs), not for reacting to clicks.

The pattern underneath all of these

Most bad useEffect code comes from treating it as a general-purpose side effect hook. It isn't. It's specifically for synchronising a component with something outside React.

If you're computing from existing state: derive it inline. If you're responding to a user action: use the event handler. If you're resetting because an identity changed: use key. The effect is the last resort, not the first one.

Once I started applying that filter, most of my effect code disappeared, and the remaining effects became much simpler.

← Older
When to Use useMemo and useCallback
Newer →
The Virtual DOM and How React Reconciliation Works

Newsletter

A weekly newsletter on React, Next.js, AI-assisted development, and engineering. No spam, unsubscribe any time.