Skip to content
Chapter 25Lesson 4

When not to use useEffect

The React judgment of when useEffect is the wrong tool, and which closer-fitting tool to reach for instead.

A teammate opens a pull request: one component, five useEffects. The first keeps a totalPrice in sync with the cart items. The second reloads a form draft whenever the selected record changes. The third fires an analytics event after a save lands. The fourth fetches the page’s initial data on mount. The fifth tells the parent that a value changed. It compiles, it runs, the demo looks fine. Four of the five are wrong.

They aren’t wrong because effects are bad. They’re wrong because each one reaches past a tool that fits better. A derived value belongs in render, an event belongs in a handler, initial data belongs on the server, and a parent notification belongs in the handler that made the change. Every misplaced effect buys you a wasted render, a window where the data is stale, and a fresh chance at an infinite loop, all for nothing when a closer tool was already there.

One idea holds the lesson together: an effect synchronizes React with a system React doesn’t own, and if you can’t name that system, it isn’t an effect. A WebSocket, a chart widget, and the browser’s matchMedia are systems React doesn’t own. A sum of cart items is not. Hold that test in your head and most of the catalog becomes obvious.

The audit: five questions before any effect

Section titled “The audit: five questions before any effect”

Before you type useEffect, run an audit: five questions, asked top to bottom, stopping at the first “yes.” The order matters, because the earlier questions catch the more common mistakes. Four of the five send you somewhere other than an effect; only the last leaves useEffect in your hands.

  1. Is this value derived from props or state you already have? Compute it in render. As you saw in “Derive, don’t mirror”, a value you can calculate from what you hold doesn’t get its own useState.
  2. Is it triggered by a specific user interaction? It’s an event handler. The work happens because the user clicked or submitted, not because some downstream state changed.
  3. Is it the page’s initial data? It’s a route loader or a Server Component that fetches before the page renders. You’ll meet this path with the App Router.
  4. Is it cached server state, something you refetch, poll, or update optimistically? It’s TanStack Query , or use() for a simple read. Caching, invalidation, and polling are a solved problem, and the solution isn’t an effect.
  5. Are you synchronizing with an external system React doesn’t own? Now it’s a useEffect: a subscription, a third-party widget, or a browser API, something with a lifecycle outside React that you set up and tear down.

Questions one through four are the same realization in four forms: the effect you were about to write is a symptom, a sign you reached past a closer tool. Only the fifth is the real thing. Walk the tree, picking the branch that matches what you’re actually modeling; at a real keyboard you’ll replay this walk in your head.

Before you write the effect…

The same audit as a table, for when you know the order and just need the question-to-tool mapping:

The questionThe tool
Can I derive it from props/state I have?Compute it in render
Did a specific user interaction trigger it?Event handler
Is it the page’s initial data?Server Component / route loader
Is it cached server state (refetch, poll, optimistic)?TanStack Query, or use()
Am I syncing with an external system React doesn’t own?useEffect, the residual case

Now the catalog. Each entry is a recognizable shape of useState and useEffect you can spot at a glance, paired with the tool that should have been there instead. We’ll walk them in audit order, so you always know which question the code failed.

The smell: state that should just be derived

Section titled “The smell: state that should just be derived”

This is the most common misuse and the highest-value fix, so we start here. The shape: a component holds a value in useState, then runs an effect to keep it in sync with the props it’s computed from.

Here’s a cart. It receives items as a prop and needs to show the total price.

export const CartSummary = ({ items }: { items: CartItem[] }) => {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, item) => sum + item.price, 0));
}, [items]);
return <p>Total: {formatMoney(total)}</p>;
};

This is the textbook anti-pattern. total lives in state, and an effect re-syncs it whenever items changes. That costs two renders: React renders once with the stale total, the effect fires, and setTotal schedules a second render. For one frame the displayed total lags the items it sums.

The shape is worth memorizing: useEffect(() => setX(somethingDerivedFromProps)), with no cleanup, is this anti-pattern every time. The missing cleanup is the tell. A real effect synchronizes with an external system and tears it down on the way out; this one just copies a value React already has into a second copy React now has to maintain. You already know how to derive state in render. The skill this lesson teaches is catching the moment you almost store it instead.

The tooling catches it too. The modern eslint-plugin-react-hooks ships a set-state-in-effect rule that flags exactly this shape: a state setter called synchronously inside an effect. When it fires, the fix is to derive the value in render, not to silence the rule. We wire up that lint config in a later lesson; for now, just know it points at the same smell.

One honest caveat: “compute it in render” draws the objection that the calculation is expensive, so won’t running it every render be slow? If the reduce is trivial, no, and you shouldn’t optimize it. If it’s genuinely heavy, the answer is still to compute it in render and let the React Compiler memoize it, or reach for useMemo where you need manual control. We cover the mechanics in the next chapter; the point here is that “it’s expensive” doesn’t promote a derived value to an effect.

This is the one refactor worth doing rather than reading, since it’s the fix you’ll reach for most. The component below works, but it carries the smell. Strip it down.

This CartSummary holds totalPrice in useState and syncs it with an effect — the derived-state anti-pattern. Remove both the useState and the useEffect, and derive totalPrice directly in the render body so the component produces the right total in a single render. The total should equal the sum of every item's price.

Preview
    Reference solution
    export const CartSummary = ({ items }) => {
    const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
    return <p>Total: ${totalPrice}</p>;
    };

    totalPrice is now an expression, evaluated every render. The total is correct the instant the component renders, with no state to hold and no effect to sync. Both the useState and the useEffect import disappear with it.

    The smell: resetting state when a prop changes

    Section titled “The smell: resetting state when a prop changes”

    This shape resets local state whenever a prop changes. An editable form is the classic case: it keeps a draft in local state so the user can type, and when the parent selects a different record, it has to reload the draft from the new record.

    The reflex is an effect. That works, but it’s the wrong shape.

    export const EditForm = ({ record }: { record: EditableRecord }) => {
    const [draft, setDraft] = useState(record);
    useEffect(() => {
    setDraft(record);
    }, [record]);
    return <textarea value={draft.body} onChange={(e) => setDraft({ ...draft, body: e.target.value })} />;
    };

    The effect re-runs whenever record changes and overwrites the draft. But there’s a beat where it’s wrong: the component renders once with the old draft, the effect fires, then it renders again with the new one. For that frame the user sees the previous record’s text.

    The lever here is component identity . Give a different key and React discards the old instance, state and all, and mounts a new one. You don’t synchronize state back to the prop; you replace the component whose state it was. This is the same mechanism from “Remounting with key”, reused for reset-on-prop-change.

    There’s one narrow exception. Sometimes you want to keep most of the user’s edits and reset only one field when a prop changes. key can’t do that — remounting would throw away the edits you meant to keep. The sanctioned pattern is to adjust that one piece of state during render, gated by a comparison against the previous value.

    export const EditForm = ({ record }: { record: EditableRecord }) => {
    const [draft, setDraft] = useState(record);
    const [lastRecordId, setLastRecordId] = useState(record.id);
    if (record.id !== lastRecordId) {
    setLastRecordId(record.id);
    setDraft((current) => ({ ...current, body: record.body }));
    }
    return (
    <textarea
    value={draft.body}
    onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
    />
    );
    };

    Store the previous record.id in state alongside the draft. This is the value we compare against to detect a change.

    export const EditForm = ({ record }: { record: EditableRecord }) => {
    const [draft, setDraft] = useState(record);
    const [lastRecordId, setLastRecordId] = useState(record.id);
    if (record.id !== lastRecordId) {
    setLastRecordId(record.id);
    setDraft((current) => ({ ...current, body: record.body }));
    }
    return (
    <textarea
    value={draft.body}
    onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
    />
    );
    };

    On every render, compare the current record.id to the stored one. When they differ, a new record just arrived, and we’re still mid-render, before React has committed anything to the screen.

    export const EditForm = ({ record }: { record: EditableRecord }) => {
    const [draft, setDraft] = useState(record);
    const [lastRecordId, setLastRecordId] = useState(record.id);
    if (record.id !== lastRecordId) {
    setLastRecordId(record.id);
    setDraft((current) => ({ ...current, body: record.body }));
    }
    return (
    <textarea
    value={draft.body}
    onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
    />
    );
    };

    Record the new id immediately, so this branch runs once per record change rather than on every later render.

    export const EditForm = ({ record }: { record: EditableRecord }) => {
    const [draft, setDraft] = useState(record);
    const [lastRecordId, setLastRecordId] = useState(record.id);
    if (record.id !== lastRecordId) {
    setLastRecordId(record.id);
    setDraft((current) => ({ ...current, body: record.body }));
    }
    return (
    <textarea
    value={draft.body}
    onChange={(e) => setDraft((d) => ({ ...d, body: e.target.value }))}
    />
    );
    };

    Reset only body and keep the rest of the draft. Calling a setter during render makes React discard this render and re-run the component right away with the new state, before it paints. The user never sees the in-between.

    1 / 1

    The smell: event logic hiding in an effect

    Section titled “The smell: event logic hiding in an effect”

    This one is subtler, because the trigger really is a user action. The bug is where the code lives. The work belongs in the handler, but it’s parked in an effect that watches the state the action happened to change.

    The tell is a question: should this happen because the user did X, or because state Y changed? A toast after a save, a redirect after a submit, an analytics ping on a click: each happens because the user did something specific. Watching state to fire that work causes two problems. The effect runs on every path that sets the state, including ones you never intended, and it always runs one render late.

    const [isSaved, setIsSaved] = useState(false);
    useEffect(() => {
    if (isSaved) {
    showToast('Invoice saved');
    }
    }, [isSaved]);
    const handleSave = async () => {
    await saveInvoice(draft);
    setIsSaved(true);
    };

    The toast is divorced from the action that should cause it. It fires on any path that flips isSaved to true, including a remount where isSaved arrives already true, showing a phantom “saved” toast the user never earned. And it lands a render after the save, not with it.

    Here’s the boundary rule: effects run because the component is displayed and needs to stay synchronized, while handler work runs because the user did something specific. If the cause is a user action, the code goes in the handler. There’s a second reason, from the first lesson of this chapter: Strict Mode double-invokes effect setups in development, but not event handlers. Park a “fire once” action in an effect and Strict Mode fires it twice, giving you a double toast or a double analytics event that surfaces the misplacement immediately.

    The smell: effect chains and notifying the parent

    Section titled “The smell: effect chains and notifying the parent”

    These two shapes share one root cause: using effects to push a single logical change through several pieces of state, or across the boundary into a parent. Each hop is a render, and each render is a chance to loop.

    A chain of effects falls like dominoes. State A changes, an effect sets B, and another effect watches B and sets C. Picking a country, resetting the region, and recomputing the tax rate is one logical transition, but split across three effects it becomes three renders, and the renders in between show a half-updated UI: new country, old region. The fix is to stop chaining and compute B and C in the same handler that sets A, so the whole transition happens at once. When a transition coordinates enough state that the handler gets unwieldy, model it atomically with useReducer, where one dispatch produces one consistent next state. Effects feeding effects feeding effects means you wanted a reducer.

    Notifying the parent is the more common bug, so we’ll see it in code. A child holds some state and wants to tell its parent when that state changes, so it reaches for an effect that calls the parent’s callback.

    const [value, setValue] = useState('');
    useEffect(() => {
    onChange(value);
    }, [value]);
    return <input value={value} onChange={(e) => setValue(e.target.value)} />;

    The parent hears about the change one render too late. The child re-renders with the new value, and then the effect fires and notifies. Worse, if the parent re-renders the child in response, the effect can fire again, and you’re one careless line away from an update loop.

    If the parent is the one that really owns this value, because both the child and a sibling need it, don’t notify at all. Lift the state up to the parent and pass it down as a prop: with one copy, there is nothing to synchronize. That’s the home concept from “Four homes for state”, and the skill is reading “I’m notifying the parent through an effect” as a sign the state lives in the wrong place.

    A related smell in the same family: a child copies a prop or context value into local state through an effect, then reads its own copy, which is always one render stale. Read it, don’t mirror it. If the value comes down as a prop or out of context, read it directly in render; mirroring it into state buys you nothing but a stale copy and a chore to keep it in sync.

    This is the big one: historically the most common reason anyone reached for useEffect, and the most thoroughly retired by 2026. You’ll see this shape everywhere in code written before 2023, and in AI-generated code that learned from it:

    const [data, setData] = useState(null);
    useEffect(() => {
    fetch(`/api/invoices`)
    .then((response) => response.json())
    .then(setData);
    }, []);
    if (!data) return <Spinner />;

    Consider what this costs. It renders once empty and again when the data lands, so two renders minimum with a spinner in between. It hand-rolls loading state. It has no error handling, so a failed request leaves the spinner forever. It has no race-condition guard, so if the inputs change before the fetch resolves you can paint stale data. The quietest and most expensive problem is that this code never runs during server-side rendering : the server ships HTML with a spinner where the content should be, which hurts the first paint the user sees and anything that reads your HTML without running your JavaScript.

    What replaces it? Work down this ladder and take the first rung that fits.

    1. Server Component awaits the data directly, the 2026 default. Write const invoices = await listInvoices(); right in a Server Component: no effect, no client JavaScript for the fetch, the data present in the first paint and fully server-renderable. Reach past this only when you can’t. (The App Router owns this path; for now, just recognize it.)

    2. use() a promise from a Server Component parent, when the consumer must be a Client Component. The parent starts the fetch and passes the unawaited promise down; the Client Component reads it with use() under a <Suspense> boundary. (Covered in the lesson after next.)

    3. TanStack Query, when you need client-side caching across views, polling, invalidation, or optimistic updates. It’s a purpose-built server-state cache, not a hand-rolled effect. (Covered later in the course.)

    So a useEffect that fetches is a code-review red flag in 2026 — though not always wrong. A residual sliver survives: an SDK that hands you only a callback with no awaitable surface, or a non-cacheable client-only POST fired mid-interaction. Even then it’s rare, and the safe way to do it is the race-condition mechanics from two lessons ago, the abort-on-resync and ignore-flag patterns. But fetching the page’s data in an effect on mount is not that case. It’s the anti-pattern, and you reshape it.

    Here’s a quick check on the shape you’ll meet most often.

    A Client Component renders a dashboard’s initial list of invoices like this:

    const [invoices, setInvoices] = useState([]);
    useEffect(() => {
    fetch('/api/invoices').then((r) => r.json()).then(setInvoices);
    }, []);

    It’s the page’s initial data, read once on load, with no polling or caching needs. What’s the right reshape?

    Fetch the invoices in a Server Component that awaits the data before render, so the list is in the first paint with no client fetch at all.
    Add an AbortController to the effect and abort it in the cleanup, so the fetch is race-safe.
    Wrap the fetch in a useEffectEvent so it isn’t reactive and the effect’s deps can stay empty.
    Move the fetch into a useMemo keyed on an empty array so it only runs once.

    Run the audit honestly and most “I need an effect” instincts dissolve. The ones that survive are real. Every legitimate case is the same thing in different forms: synchronizing with a system React doesn’t own, set up on the way in and torn down on the way out. The five categories below are the ones you’ll actually meet; read each as an answer to “what does this effect synchronize with?”

    Real-time connections

    A WebSocket, an EventSource/SSE stream, a BroadcastChannel. Setup opens the connection; cleanup closes it. (The chat-room example from the last two lessons lives here.)

    Third-party widgets

    A chart library, a map, Stripe Elements, a video player: anything that takes a DOM node. React renders the container, the effect instantiates the widget against the node, and cleanup destroys it.

    Browser APIs React doesn't model

    matchMedia, IntersectionObserver, ResizeObserver, a raw scroll or resize listener. Subscribe in setup, unsubscribe in cleanup.

    Native element state

    Driving a <dialog> or <details> open and closed from React state. The element’s open status is the external state you’re syncing to.

    Non-React script init

    A third-party script that must run against the live DOM. The effect sets it up once the node exists; cleanup tears it down.

    Every one of these returns a cleanup that tears down exactly what it set up: a connection to close, a widget to destroy, a listener to remove. If your effect has no external system and an empty cleanup, it doesn’t belong here. It’s one of the catalog smells above in disguise.

    One “store a value in an effect” pattern is the exception that proves the rule. Writing a prop’s previous value to a ref looks like the mirror smell, but the ref is a tiny external store, and the effect genuinely synchronizes it. You’ll meet this packaged as a reusable usePrevious hook in the next chapter. For now, file it as real synchronization, not a violation.

    Every smell above reduces to one reflex you can run against any effect, your own or a teammate’s or an AI’s. Two questions:

    1. What external system does this effect synchronize with?
    2. What does its cleanup tear down?

    If the honest answers are “none” and “nothing,” the effect is almost certainly one of the catalog’s anti-patterns, so reshape it. You don’t have to recall all eight smells by name; you have to ask those two questions and trust a blank answer to expose a misplaced effect.

    An effect is never free. Each one adds a render, a stale-closure surface, a possible loop, and a line someone must reason about every time they touch the file. One misplaced effect costs little. But a codebase with dozens of effects scattered across hundreds of components almost certainly hides a race, a loop, or an ordering bug somewhere, and the only question is whether you’ve found it yet. The discipline isn’t “use effects well”; it’s reaching for useEffect only when nothing else fits, and the audit is how you know.

    To make the reflex muscle memory, try it on a small two-file pull request from a teammate. It compiles and works in a quick click-through, and it carries the catalog’s smells. Review it as you would for real, running the two questions on every effect you find.

    Review this PR the way you would for a teammate — flag anything that should be reshaped, not just bugs that crash. Run the two questions on every effect. Click any line to leave a review comment, then press Submit review.

    invoice-list.tsx
    'use client';
    export const InvoiceList = () => {
    const [invoices, setInvoices] = useState([]);
    const [total, setTotal] = useState(0);
    useEffect(() => {
    fetch('/api/invoices')
    .then((r) => r.json())
    .then(setInvoices);
    }, []);
    useEffect(() => {
    setTotal(invoices.reduce((sum, inv) => sum + inv.amount, 0));
    }, [invoices]);
    return (
    <div>
    <p>Total outstanding: {formatMoney(total)}</p>
    <ul>{invoices.map((inv) => <li key={inv.id}>{inv.number}</li>)}</ul>
    </div>
    );
    };

    This lesson is built on the React docs’ own audit. “You Might Not Need an Effect” is the canonical long-form catalog, and “Synchronizing with Effects” is its counterpart, mapping the surface where effects genuinely belong. Read both as the reference companions to this lesson.