An effect is for synchronizing React with an external system. It is a poor place to copy one state value into another or compute a filtered list from current props. That copy renders an out-of-date intermediate state, adds another update, and creates another place where the values can disagree. Derived values should be calculated during render when cheap, or memoized when a measured expensive calculation depends on stable inputs. A user command such as submitting an approval belongs in the event path that knows why it happened. An effect that watches a status flag cannot reliably tell whether the change came from a user click, restored data, or a background refresh.
React Derived State and Event Ownership
Working case
A permit queue keeps all cases, a search phrase, filtered cases, and a visible count in separate state variables. An effect rebuilds filtered cases after the input changes; another effect updates the count. The UI briefly shows the new phrase beside the old count, and a network refresh can race with both effects. An approval effect watches an 'approved' boolean and posts a mutation when it turns true. Restoring a saved view with that flag can post the approval again. The rewrite stores source cases and the phrase, derives the visible list and count in the render, and sends the mutation only in the explicit approval command with a stable operation ID.
Implementation boundary
import { useMemo, useState } from "react";
function PermitSearch({ cases, onApprove }) {
const [phrase, setPhrase] = useState("");
const visibleCases = useMemo(() => {
const needle = phrase.trim().toLocaleLowerCase();
return cases.filter((permitCase) => permitCase.title.toLocaleLowerCase().includes(needle));
}, [cases, phrase]);
return (
<section>
<input type="search" aria-label="Find a permit" value={phrase} onChange={(event) => setPhrase(event.target.value)} />
<p>{visibleCases.length} cases</p>
{visibleCases.map((permitCase) => <button type="button" key={permitCase.id} onClick={() => onApprove(permitCase.id)}>Approve {permitCase.title}</button>)}
</section>
);
}Identify source state: server snapshot, local search phrase, selected case, and pending operation. Derive filtered rows and count from those sources in one render. If filtering 47,000 rows is expensive, measure it, then memoize using the source list and search phrase as dependencies; memoization is a cache, not a correctness mechanism. Normalize the phrase once and use a stable comparison rule. Keep network mutation in the click handler or an action boundary, validate the server response, and update authoritative state when it returns. Do not make an effect infer a user intent from a boolean. Real external synchronization, such as subscribing to a browser event, still belongs in an effect with cleanup. For fetching when the selected case changes, handle cancellation and stale responses as a separate request race.
Cost and boundaries
Deriving values during render uses CPU on each relevant render but removes extra state updates and drift. Memoization stores a prior result and dependency comparison; it can add complexity without benefit on small lists. A large filter may need indexing, a worker, or a deferred render, each with its own data-copy and scheduling cost. An explicit event mutation makes the effect count predictable and gives one place for idempotency and error handling. Measure render duration and count redundant requests. Do not turn every calculation into a memo by habit; first establish that the cost matters on target devices.
Failure trace
Type a phrase while a new server snapshot arrives. The rows and count must correspond to the same render, not to two effect updates at different times. Restore a view with an approved status flag; no approval request should be emitted without a user command. Click Approve twice quickly and verify the operation identity and pending state prevent duplicate effects. Simulate a rejected approval and keep the visible record consistent with the server. Switch the selected case while its detail fetch is in flight and reject the stale result. Repeat with an empty list and a phrase containing mixed case or whitespace.
Verification
- Visible rows and count come from the same source inputs.
- A restored status never fires a command mutation.
- An effect exists only where an external system needs synchronization.
Practice drill
Start with 47 permit cases, a phrase field, and a derived count. Remove the stored filtered-list and count states. Measure render time before introducing a memo; then expand to 47,000 records and decide whether the cost justifies one. Add an approval button whose handler sends a stable operation ID to the server. Replay a restored approved record, a background snapshot refresh, a double click, and a failed request. Count actual approval network calls. Write down which effect remains for a genuine external subscription and its cleanup event.
Decision note
If a value can be calculated from current render inputs, do not synchronize a second copy of it.
Common Mistakes
- Copying filtered rows into state with an effect.
- Posting an approval because a boolean happened to change.
- Adding memoization before measuring a render bottleneck.
Related lessons
React State and Rendering Boundaries; React Component Identity and Keyed Draft Lifetime; React Urgent Input and Transition Work; React Hydration and Stable First Render; React Effects and Request Races; Interaction Latency Traces and Main-Thread Contention.
Apply and check
Build Project: React permit review queue state and review Web Development: React state and rendering quiz.
