| name | conventions-use-effect |
| description | Apply when writing or reviewing React effects in apps/web. Direct useEffect is banned; use the sanctioned wrappers or a better primitive. |
useEffect Conventions
Apply when writing or reviewing React code in apps/web.
Direct useEffect is banned in apps/web/src and enforced by the
no-raw-use-effect lint rule. Most effects compensate for primitives React
already gives you; the rest are external-system synchronization that must go
through a named wrapper so intent is explicit and greppable. React Compiler is
enabled tree-wide, so "derive during render" costs nothing.
Background: React's own guide,
You Might Not Need an Effect.
Decision order
Before reaching for any effect, walk this list top to bottom and stop at the
first match:
- Can it be derived during render? Then derive it. (Rule 1)
- Is it data fetching? Use TanStack Query. (Rule 2)
- Does it happen in response to a user action? Do it in the event handler. (Rule 3)
- Does it reset state when an id/prop changes? Remount with
key. (Rule 5)
- Is it genuine external-system synchronization? Use
useMountEffect
or useExternalSyncEffect. (Rule 4)
If none match, you almost certainly do not need an effect.
Rule 1 — derive state, do not sync it
const [filtered, setFiltered] = useState([]);
useEffect(() => setFiltered(products.filter((p) => p.inStock)), [products]);
const filtered = products.filter((p) => p.inStock);
Smell: useEffect(() => setX(deriveFrom(y)), [y]), or state that only mirrors
other state/props.
Rule 2 — data-fetching library, not an effect
useEffect(() => { fetchProduct(id).then(setProduct); }, [id]);
const { data: product } = useQuery(productOptions(id));
Smell: an effect that does fetch(...) then setState(...), or re-implements
retries/cancellation/staleness.
Rule 3 — event handlers, not effects
useEffect(() => { if (liked) { postLike(); setLiked(false); } }, [liked]);
<button onClick={() => postLike()}>Like</button>
Smell: state used as a flag so an effect can run the real action
("set flag → effect runs → reset flag").
Rule 5 — reset with key, not dependency choreography
<Editor key={documentId} documentId={documentId} />
Smell: an effect whose only job is to reset local state when an id changes.
Rule 4 — the two sanctioned wrappers
Both live in @/hooks/use-effect and are the only place a raw useEffect
may be called.
useMountEffect(effect)
Setup/teardown on mount, once. For DOM imperatives (focus, scroll), third-party
widget lifecycles, and browser-API subscriptions.
useMountEffect(() => {
const controller = new AbortController();
window.addEventListener("resize", onResize, { signal: controller.signal });
return () => controller.abort();
});
useExternalSyncEffect(effect, deps)
Push a changing React value into an external system when it changes. The
only sanctioned dependency-array effect. Every call must be an external-system
sync — never derived state, an event relay, or a fetch.
useExternalSyncEffect(() => editorRef.current?.setZoom(zoom), [zoom]);
When the external lifecycle is "set up once per DOM node," use a callback
ref over an effect (it ties setup/teardown to the node, not to a render) —
see the ResizeObserver/fit-zoom pattern in the docx viewers. This is the
canonical shape for ResizeObserver, IntersectionObserver, DOM listeners,
and imperative widgets whose lifecycle belongs to a specific element. Keep an
effect only when the component does not own the node/ref API yet, or when the
work is truly "push a changing React value into an already-attached external
system."
useLatestCallback(fn) — latest-values callbacks for external systems
When a callback must be handed to something that outlives the render that
created it (a listener registered inside useExternalSyncEffect, a query
context object, an imperative editor/runtime bridge) and it should read the
latest committed state without re-triggering the subscription, wrap it in
useLatestCallback from @/hooks/use-latest-callback: stable identity,
always invokes the latest closure, creates no reactivity.
Do not reach for React's useEffectEvent: its contract restricts calls to
raw useEffect bodies of the same component, which the wrapper-only policy
above makes unsatisfiable — react-hooks/rules-of-hooks flags every such
use. useLatestCallback has the same semantics minus that restriction. Two
contract points: never call the result during render, and list it in the
useExternalSyncEffect deps array when used there (its identity is stable,
so it never causes re-runs; the entry only satisfies exhaustive-deps).
Feedback loops on imperative-editor boundaries
Any state write triggered by an external editor or subscription callback
(tiptap onUpdate, folio dispatch, store subscriptions) must be a no-op when
nothing semantically changed. Otherwise write → re-render → editor re-emits →
write sustains itself until React throws "Maximum update depth exceeded"; the
loop typically only ignites under a re-render storm (e.g. response streaming),
so it survives casual testing.
The no-op guard's comparator must be identity-based, not structural. Track
the exact object you last handed to (or received from) the editor — a
WeakSet/ref identity latch, as in shouldApplyStoredDraftToEditor in
chat-editor-provider.tsx. JSON.stringify or shallow equality is not
sufficient on an editor boundary: imperative editors serialize non-canonically
(getJSON/setContent round-trips are not idempotent — attrs appear, empty
content: [] comes and goes), so a semantic no-op reads as a change and the
loop walks straight through a structural guard. Do not "simplify" an identity
latch into a structural comparison.
Context values: never fold volatile state into a stable API
A context value memoized as a stable API must not carry any field that changes
at runtime (version counters, monotonic bump values, activeX keys). One
volatile field gives the whole context a new identity on every bump, so every
consumer re-renders and its registration effects re-fire — and if registering
bumps the counter, that is a self-sustaining loop (damped normally, explosive
under load). Split volatile values into their own context, or expose them via
ref + subscribe; the stable-API context's fields must be referentially
permanent for the provider's lifetime.
useLayoutEffect
Not covered by the ban (it has legitimate pre-paint imperative uses), but the
same decision order applies. Reach for it only when a measurement or imperative
write must happen before the browser paints.
Stale async responses
An in-flight async call that resolves after the component has moved on — a
route navigation, an entity/id switch, or a newer request superseding an
older one — can still land its result after nothing should be listening
anymore. Left unguarded, the stale response applies over a fresher one and
the UI shows wrong data. Two situations, two different fixes.
TanStack Query: thread the signal
useQuery/useInfiniteQuery/queryOptions pass an AbortSignal to every
queryFn via its first argument. A queryFn that never destructures it
never cancels a superseded call. Thread it into both fetch and Eden calls:
queryFn: async ({ signal }) => {
const response = await api.things.get({ fetch: { signal } });
...
};
Combine it with a call-specific timeout via
AbortSignal.any([signal, AbortSignal.timeout(ms)]) when the call also needs
an upper bound independent of query cancellation (see fetchPrintPdf in
peek-pdf-viewer.tsx). Enforced in apps/web/src by the
require-query-signal lint rule, scoped to queryFn bodies that call
fetch/Eden directly.
Hand-rolled async + setState: the render-time identity latch
Manually paged/accumulated state — seeded from a query's data and then
extended by imperative "load older" calls — has no queryFn for TanStack to
cancel, so it needs its own guard.
The fix is a request-generation identity latch: a ref holding the
current "generation" (the query's data object identity, or an equivalent
runtime instance), written synchronously during render rather than in a
passive effect — that closes the commit→effect window a response could
otherwise resolve into undetected. The async callback captures the ref
before starting the fetch and compares it again after the await resolves; a
mismatch means the page/entity changed underneath the request, so the
response is discarded instead of applied:
const seededDataRef = useRef(data);
if (data !== undefined && seededData !== data) {
setSeededData(data);
seededDataRef.current = data;
}
const loadOlder = useCallback(async () => {
const requestedData = seededDataRef.current;
const older = await fetchOlderVersions();
if (seededDataRef.current !== requestedData) {
return;
}
setAccumulated((current) => [...current, ...older.versions]);
}, []);
In-repo exemplars: apps/web/src/components/inspector/versions-facet.tsx
(seededDataRef) and
apps/web/src/routes/_protected.chat/-hooks/use-chat-session.ts
(seededChatRef). This is a narrow, explicitly allowlisted exception to
no-ref-mirror (not a general recipe to reach for) — useLatestCallback
(and React's useEffectEvent) do not protect this window because their
updates are effect-timed while the write must happen during render. A new
call site needs its own justified entry in that rule's allowedFiles in
oxlint.config.ts.
Escape hatch
For a genuine effect that does not fit either wrapper (or that is pending
migration), suppress at the call site with a reason — suppression-hygiene
requires the description:
useEffect();
Prefer fixing over suppressing. A bare disable with no reason is itself a lint
error.
Scope
Enforced in apps/web/src. packages/folio is intentionally exempt: it is
upstream-synced and is an inherently imperative editor where most effects are
legitimate external-system sync, so the rule would generate suppression noise
and fight every upstream merge for little benefit.