Our take on useEffect()

March 27, 2026 (3mo ago)

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:

  1. can i compute this during render from existing state/props?
  2. is this triggered by a user event? use an event handler.
  3. do i need to reset state when an id changes? use a key prop.
  4. am i fetching data? use a query library.
  5. 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.ts family — interval and Supabase Realtime subscription lifecycles
  • components/spreadsheet/** — third-party grid lifecycles
  • pdf-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