While building out Invoice Intelligence for Oaisis, we took upon the same patterns others have noticed about useEffect(). Most times, it never needed an effect or hook to begin with.
How we structured it
Banning a hook only works if there's somewhere to go. The rule points at
docs/frontend-no-useeffect.md, which is a decision tree.
Before reaching for an effect, you answer five questions in order:
- can i compute this during render from existing state/props?
- is this triggered by a user event? use an event handler.
- do i need to reset state when an id changes? use a
keyprop. - am i fetching data? use a query library.
- does this sync with an external system on mount?
useMountEffect
Rule 1 the most useful pattern
The single most common anti-pattern we deleted was the sync-a-second-useState:
You see this in our invoice review screen. The merged view of an invoice —
server data, the user's unsaved edits, and OCR extraction layered in priority
order — is derived data. So instead of storing it in state and keeping it
synchronized with everything it depends on, we recompute it directly from its
inputs (activeInvoice, editedMetadata, and editedLineItems) during render:
const mergedInvoice = useMemo(() => {
if (!activeInvoice) return null;
return {
...activeInvoice,
...editedMetadata,
...(editedLineItems !== null ? { line_items: editedLineItems } : {}),
};
}, [activeInvoice, editedMetadata, editedLineItems]);The alternative would be to give it its own state and sync it with an effect:
const [mergedInvoice, setMergedInvoice] = useState(null);
useEffect(() => {
setMergedInvoice({
...activeInvoice,
...editedMetadata,
...(editedLineItems !== null ? { line_items: editedLineItems } : {}),
});
}, [activeInvoice, editedMetadata, editedLineItems]);That creates a synchronization problem. When one of the inputs changes, React
first renders with the old mergedInvoice, then the effect runs, then React
renders again with the updated value. For that brief window between the two
renders, mergedInvoice is out of sync with the sources it's supposed to reflect —
and anything reading it in between sees a stale merge. The useMemo version
avoids that entirely: mergedInvoice is computed from the latest inputs on every
render, so it can never disagree with them.
Rule 5
When you need to sync with an external system on mount, a DOM
listener, a matchMedia query, or a third-party editor widget, it's
tempting to go for useEffect. Instead, exactly one wrapper is the solution:
// The only sanctioned replacement for useEffect in this codebase.
export function useMountEffect(effect: () => void | (() => void)): void {
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(effect, []);
}It's a one-line file since by forcing the dependency array to be
empty by construction*, useMountEffect runs once, it cleans up on unmount,
and it does not react to
anything. If your effect body needs to react to a changing value, the empty
array tells you that you actually wanted a key
key or a derivation, not a reactive effect.
Exemptions
It's hard to achieve zero effects. We achieved zero unexplained effects.
The doc has a section titled "Known exemptions (don't rewrite without tests)"
listing the files that knowingly keep a direct useEffect:
use-auto-save.ts— debounced save with stale-closure refs- the
use-*-polling.ts/use-invoice-progress.ts/use-notifications.tsfamily — interval and Supabase Realtime subscription lifecycles components/spreadsheet/**— third-party grid lifecyclespdf-viewer.tsx— DOM polling for the embed host node
These are reactive effects: a subscription that has to re-bind
when open changes genuinely needs a non-empty dependency array. For those, the
rule degrades from an error to a warning, and you silence it with an
inline disable that must carry a one-line reason:
// eslint-disable-next-line no-restricted-syntax -- subscription re-binds when `open` changes
useEffect(() => { /* … */ }, [open]);"Needed to make it work", "TODO", and a blank reason are explicitly listed as unacceptable.
Caveats
Refs-that-track-latest are a real and necessary pattern. Inside an interval or a subscription callback, you will read a stale closure unless you mirror the latest prop/callback into a ref. Banning it would have made our polling hooks worse, not better.
The reason we didn't lead with: agents
We framed this rule around bug classes, but there was a second reason we cared: banning useEffect is really about agents.
Agents have become primary contributors to this codebase,
and it’s harder for them to simulate timelines replaying dependencies in useEffect().
When an agent reads a component with chained effects, it hits a structural problem. The component mounts, then Effect A fires and updates state X. X is in the dependency array of Effect B, so that fires next and updates state Y, which re-renders, which triggers the next effect. To understand what the component does right now, the agent has to reconstruct what happened over time. It has to simulate a timeline.
Agents aren't very good at simulating timelines. Compare that to a component that
derives everything inline — this is ValidationErrorsBanner, shipping today in our
invoice review screen:
export function ValidationErrorsBanner({
errorMessage,
className,
}: ValidationErrorsBannerProps) {
const [expanded, setExpanded] = useState(false);
const translatedErrors = useMemo(
() => translateValidationErrors(errorMessage),
[errorMessage]
);
return (
<div role="alert" className={cn("rounded-xl border …", className)}>
<button type="button" onClick={() => setExpanded((prev) => !prev)}>
<AlertCircle aria-hidden="true" />
<p className="font-medium">
Processing completed with {translatedErrors.length}{" "}
{translatedErrors.length === 1 ? "issue" : "issues"}
</p>
</button>
{expanded && (
<ul>
{translatedErrors.map((err, idx) => (
<li key={idx}>
<span>{err.userMessage}</span>
<p>{err.action}</p>
</li>
))}
</ul>
)}
</div>
);
}translatedErrors is derived from the
errorMessage prop with a useMemo; expanded is plain UI state flipped in an
onClick (Rule 3 — event handlers, not effects). Props come in, values are derived,
JSX goes out. An agent knows exactly what this does by reading top to bottom;
there's no hidden transient dimension to reconstruct.
**useEffect turns a tree into a timeline. Banning it
turns the timeline back into a tree, something agents can parse.
Lint rules as agent guardrails
Once you see the codebase as something agents navigate,
these are less of one-off instances: the same logic drives
the rest of our config. Named exports over default exports (an agent can grep for
a specific function), absolute imports over relative paths (no resolving
../../..), colocated test files (find tests without guessing at directory
conventions). Every rule makes the codebase deterministic enough that an agent can
navigate it without burning context on guesswork.
It's the same principle as a .cursor/rules file. Editor rules shape what an agent generates;
lint rules shape what's allowed to
exist at all, regardless of whether a human or an agent wrote it; CI is the third
layer that blocks what can merge.
.cursor/rules/ → shapes what the agent generates
ESLint / lint rules → blocks what can be committed
CI / pre-commit → blocks what can be merged
If you rely on the agent to "remember" your architectural preferences and you're trusting a new hire to recall every onboarding convention on every task, that's why we've encoded the constraint once and every session inherits it.
*Acknowledgments: Rachel Cantor's post, and Alvin Ng's thread