useEffect has a specific job: keep a component in sync with something outside React. That means the DOM, a WebSocket, a timer, a browser API, or a third-party widget.
Most of the effects I remove in code review aren't doing that. They copy one piece of state into another, or run event logic after the fact. That causes extra renders, flicker, and "why is this stale?" bugs. React's own docs have a page on exactly this: You Might Not Need an Effect.
Here are the six cases I see most often, with the fix for each.
1. Derived state
Before:
const [first, setFirst] = useState(""); const [last, setLast] = useState(""); const [fullName, setFullName] = useState(""); useEffect(() => { setFullName(`${first} ${last}`); }, [first, last]);
This renders twice on every keystroke: once with the stale fullName, then again after the effect updates it.
After:
const fullName = `${first} ${last}`;
If you can compute it from props or state, compute it during render. Don't store it.
2. Filtering or sorting a list
Before:
const [visible, setVisible] = useState<Todo[]>([]); useEffect(() => { setVisible(todos.filter((t) => (showDone ? true : !t.done))); }, [todos, showDone]);
After:
const visible = useMemo( () => todos.filter((t) => (showDone ? true : !t.done)), [todos, showDone], );
Use useMemo only if the calculation is actually slow. For a few hundred items, a plain const is fine. With the React Compiler enabled, you often don't need useMemo at all.
3. Logic that belongs in an event handler
Before:
const [submitted, setSubmitted] = useState(false); useEffect(() => { if (submitted) { post("/api/order", cart); toast("Order placed"); } }, [submitted]); <button onClick={() => setSubmitted(true)}>Buy</button>
After:
async function handleBuy() { await post("/api/order", cart); toast("Order placed"); } <button onClick={handleBuy}>Buy</button>
Ask: why does this code run? If the answer is "because the user did something", it belongs in the event handler. Effects run because the component was displayed, not because of an action.
4. Resetting state when a prop changes
Before:
function Profile({ userId }: { userId: string }) { const [comment, setComment] = useState(""); useEffect(() => { setComment(""); }, [userId]); // ... }
The old comment flashes for one render before it clears.
After: let React reset it with a key:
<Profile key={userId} userId={userId} />
A different key means a different component instance, so all of its state starts fresh.
5. Telling the parent about a change
Before:
function Toggle({ onChange }: { onChange: (on: boolean) => void }) { const [on, setOn] = useState(false); useEffect(() => { onChange(on); }, [on, onChange]); return <button onClick={() => setOn(!on)}>{on ? "On" : "Off"}</button>; }
After: update both in the same event:
function Toggle({ onChange }: { onChange: (on: boolean) => void }) { const [on, setOn] = useState(false); function toggle() { const next = !on; setOn(next); onChange(next); } return <button onClick={toggle}>{on ? "On" : "Off"}</button>; }
Or lift the state up and make Toggle fully controlled by its parent.
6. Chains of effects
useEffect(() => { if (card) setGold(g => g + 1); }, [card]); useEffect(() => { if (gold > 3) setRound(r => r + 1); }, [gold]); useEffect(() => { if (round > 5) setGameOver(true); }, [round]);
Each step costs a render, and the logic is spread across three places. Compute what you can during render, and do the rest in one event handler that calculates the next state all at once.
What about data fetching?
Fetching in an effect works, but you have to handle race conditions, loading and error states, and caching yourself:
useEffect(() => { let ignore = false; fetch(`/api/user/${id}`) .then((r) => r.json()) .then((data) => { if (!ignore) setUser(data); }); return () => { ignore = true; }; }, [id]);
Better options, depending on your stack:
- Next.js App Router: fetch in a Server Component. No effect, no loading flash. See Next.js App Router in Production.
- Client-side: TanStack Query or SWR handle caching, deduplication and race conditions for you.
When an effect is the right tool
useEffect(() => { const socket = connect(roomId); return () => socket.disconnect(); }, [roomId]);
Connecting to an external system, subscribing to window events, controlling a non-React widget (a map, a video player), or measuring the DOM. If there's no outside system, look for one of the six refactors above.
A quick test before you write useEffect
- Can I calculate this from existing props and state? → compute it during render
- Is this caused by a user action? → event handler
- Do I need fresh state when an ID changes? →
key - Am I syncing with something outside React? →
useEffect
Fewer effects also makes components easier to test. See Testing React Apps.