Skip to content
Chapter 25Lesson 4

You probably don't need an effect

The senior React judgment of when a 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. And four of those five effects are wrong.

They aren’t wrong because effects are bad. The last three lessons were spent learning to use them well. They’re wrong because each one reaches past a tool that fits the job better. A derived value belongs in render. An event belongs in a handler. Initial data belongs on the server. A parent notification belongs in the same handler that made the change. Each misplaced effect adds a wasted render, a window where the data is stale, and a fresh chance at an infinite loop, and these are all costs you pay for nothing when a closer tool was already available.

Lessons 1 through 3 taught how effects work: Strict Mode as the messenger, the setup-and-cleanup lifecycle, the non-reactive seam. This lesson teaches the judgment that comes before any of that, which is whether to write one at all. You’ll leave with two things. The first is a reusable audit, five questions you run before writing any effect. The second is a catalog of the named anti-patterns, so you recognize each one the instant you see it and know the tool that should have been there instead.

One idea holds the whole 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. It’s five questions, and the order matters: you ask them top to bottom and stop at the first “yes,” because the earlier questions catch the cheaper, more common mistakes. Four of the five send you somewhere other than an effect. Only the last one ends with useEffect in your hands.

  1. Is this value derived from props or state you already have? Then compute it in render. You met this in “Derive in render, do not mirror into state”: values you can calculate from what you already hold don’t get their own useState.
  2. Is it triggered by a specific user interaction? Then it’s an event handler. The work happens because the user clicked or submitted, not because some state downstream of that changed.
  3. Is it the page’s initial data? Then it’s a route loader or a Server Component that fetches before the page renders. You’ll meet this path properly when we reach the App Router.
  4. Is it cached server state, something you refetch, poll, or update optimistically? Then 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 below, picking the branch that matches a situation you’re actually modeling and following it to the verdict. The order of the questions matters more than the individual leaves, because at a real keyboard you’ll replay this exact walk in your head.

Before you write the effect…

Below is the same audit as a table, for when you’ve internalized the order and just need the question-to-tool mapping in front of you.

| The question | The 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 |

With the audit in place, here is the catalog. Each entry is a shape, a recognizable pattern 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 is a component that 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 is stored in state and an effect re-syncs it whenever items changes. It 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’s supposed to sum.

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

The tooling now catches this for you, 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 almost never to silence it. The fix is to derive the value in render. We’ll cover wiring up that lint config in a later lesson on the rules of hooks. For now, just know the rule exists and points at the same smell.

There is one honest caveat. “Compute it in render” sometimes 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 the computation is genuinely heavy, the answer is still to compute it in render: you let the React Compiler memoize it for you, or reach for useMemo where you need manual control. The fix for an expensive derived value is never an effect plus a piece of state. We’ll get to the mechanics of memoization in the next chapter. The point here is only that “it’s expensive” doesn’t promote a derived value to an effect.

This is the one refactor worth doing rather than just reading, because it’s the single most common fix you’ll make. The component below works, but it’s carrying 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. No state to hold, no effect to sync, no stale frame: the total is correct the instant the component renders. 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”

    The next shape watches a prop and resets some state when it changes. An editable form is the classic example: it keeps a draft in local state so the user can type, and when the parent selects a different record, the form needs to reload the draft from the new record.

    The reflex is to reach for an effect. That works, but it’s still 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 every time record changes and overwrites the draft. But there’s a beat where it’s wrong: when record changes, the component first renders with the old draft, the effect fires, and then it renders again with the new one. The user sees the previous record’s text flash for a frame.

    The lever here is component identity . React identifies each instance by where it sits in the tree and its key. Give a different key and you’ve told React “this is a different thing,” so it discards the old instance, state and all, and mounts a new one. That’s the cleanest possible reset: you don’t synchronize the state back to the prop, you replace the component whose state it was. The key-as-identity mechanism is the home concept from “Remounting with key”, and here you’re just recognizing reset-on-prop-change as a job it does for free.

    There’s a narrow exception, and it’s worth knowing precisely because it’s narrow. Sometimes you don’t want to reset the whole form. You want to keep most of the user’s edits but reset one field when a prop changes. You can’t use key for that, because 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 }))}
    />
    );
    };

    Immediately record the new id, so this branch runs exactly once per record change, not on every render after it.

    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 the field we want, 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

    This is the only place React sanctions calling a setter during render, and it does so on purpose: React notices the state changed mid-render, throws away the in-progress output, and immediately re-runs the component with the updated state, all before anything reaches the screen. No flash, no effect, no extra paint. But it’s the rare case, not the common one.

    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 that runs when the user acts, but instead it’s parked in an effect that watches the state the action happened to change.

    The tell is a question you can ask out loud: 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: every one of those 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, which shows 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 to keep event work out of effects, and you already met it in the first lesson of this chapter. Strict Mode double-invokes effect setups in development, but it does not double-invoke event handlers. Park a “fire once” action in an effect and Strict Mode will fire it twice, giving you a double toast or a double analytics event, which surfaces the misplacement immediately. The handler version doesn’t have this problem at all.

    The smell: chains of effects and notifying the parent

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

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

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

    Notifying the parent is the more common real bug, so it’s the one we’ll see 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 it turns out 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, pass it down as a prop, and the synchronization problem disappears because there’s only one copy. That’s the home concept from “The four homes for state”, and the skill here is recognizing “I’m notifying the parent through an effect” as a sign the state lives in the wrong place.

    There’s a related smell in the same family: a child that copies a prop or context value into local state through an effect, then reads its own copy. The copy is always one render stale, and the fix is one sentence: 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 a stale copy and a synchronization chore, and nothing else.

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

    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, then 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. And the quietest, most expensive problem is that this code does not run during server-side rendering . Effects only run in the browser, so when the server renders this page, data is null and the HTML ships with a spinner in it. The page can’t be server-rendered with its actual content, which is bad for the first paint the user sees and bad for anything that reads your HTML without running your JavaScript.

    So 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, and the data present in the very 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 genuinely must be a Client Component. The Server Component parent starts the fetch and passes the unawaited promise down, and the Client Component reads it with use() under a <Suspense> boundary. The lesson after next covers this in full.

    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.)

    The rule to walk away with is that a useEffect that fetches is a code-review red flag in 2026. It isn’t always wrong, though. There’s a genuinely residual sliver where it survives: an SDK that only hands you a callback with no awaitable surface, or a non-cacheable client-only POST fired mid-interaction. Even then it’s rare, and even then the way to do it safely is the race-condition mechanics you learned two lessons ago, the abort-on-resync and ignore-flag patterns. But “fetch the page’s data in an effect on mount” is not that residual case. It’s the anti-pattern, and you reshape it.

    Here is a quick check on the shape, since this is the one you’ll meet most in the wild.

    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, but not all of them, and the goal here isn’t to leave you avoiding useEffect on sight. The fifth question’s case is real, and an experienced engineer reaches for it without hesitation when the job actually calls for it. Every legitimate case is the same thing in different forms: synchronization 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, and you can 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, or 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> open and closed, or a <details>, from React state. The element’s open or closed status is the external state you’re syncing to.

    Non-React script init

    A third-party script that must be set up against the live DOM. The effect runs the setup once the node exists; cleanup tears it down.

    The through-line is the test from the start of the lesson, now sharpened into a rule: every one of these returns a cleanup that tears down exactly what it set up, whether that’s a connection to close, a widget to destroy, or a listener to remove. If your effect has no external system and an empty cleanup, it doesn’t belong in this section. It’s one of the catalog patterns above in disguise.

    One legitimate “store a value in an effect” pattern is worth naming, as the exception that proves the rule. Writing the previous value of a prop to a ref, the building block of a usePrevious helper, looks like the mirror smell, but it isn’t: the ref is a tiny external store, and the effect is genuinely synchronizing it. That’s why it’s the one sanctioned case of stashing a value in an effect. You’ll see it packaged as a reusable usePrevious custom hook when we get to custom hooks in the next chapter. For now, just file it as a real synchronization, not a violation.

    Everything above compresses into a reflex you can run in your head against any effect, whether it’s your own, 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. That two-question filter is the same audit from the start of the lesson, run in review instead of while writing. You don’t need to remember all eight smells by name. You need to ask those two questions and let a blank answer tell you something’s wrong.

    Why does this matter enough to spend a whole lesson on it? Because an effect is never free. Each one adds a render, a stale-closure surface, a possible loop, and a line of maintenance liability that someone has to reason about every time they touch the file. One misplaced effect costs little on its own. But a codebase with dozens of effects scattered across hundreds of components almost certainly has an ordering bug, a race, or a loop somewhere in it, and the only question is whether you’ve found it yet. The discipline that marks an experienced engineer here isn’t “use effects well.” It’s to reach for useEffect only when nothing else fits, and the audit is how you know.

    To make the reflex muscle memory, start with a fast sort. Each scenario below belongs in exactly one bucket, so drop it where the audit sends it.

    Sort each situation into the tool the audit points you to. Drag each item into the bucket it belongs to, then press Check.

    Derive in render A value computed from props/state you already hold
    Event handler Work triggered by a specific user action
    Server / cached data Initial page data, or cached server state you refetch or poll
    Real useEffect Synchronizing with an external system React doesn't own
    The filtered list shown from a search box’s text and a full dataset
    Whether a “Submit” button is enabled, from whether the form has errors
    Showing a success toast after the user saves a record
    Navigating to a new page after a form submits successfully
    The dashboard’s initial list of invoices on first load
    A comment thread that polls for new replies every few seconds
    Connecting to a chat server when a room opens, disconnecting when it closes
    Instantiating a charting library against a div once it’s on the page

    Now for the real thing. Below is a small pull request, two files, from a teammate. It compiles and the feature works in a quick click-through. It also carries the catalog’s smells. Read it the way you’d review it for real: click any line that’s wrong and leave a comment naming the defect and the reshape. Run 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>
    );
    };

    That review is the reflex in miniature. The skill isn’t memorizing eight smells by name. It’s running the two questions on any effect you meet and trusting a blank answer to expose a misplaced one.

    This lesson is built directly on the React docs’ own audit. “You Might Not Need an Effect” is the canonical long-form catalog, worth reading as the reference companion to this lesson, and “Synchronizing with Effects” is its counterpart, the page that maps out the residual surface where effects genuinely belong.

    If you’d rather watch the whole catalog worked through in code, the video below is the closest companion to this lesson.