useEffect Conventions

SkillDev tools

Apply when writing or reviewing React effects in apps/web. Direct useEffect is banned; use the sanctioned wrappers or a better primitive.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the useEffect Conventions skill

What this skill tells your AI

The instructions your AI receives, as published by stella/stella in .agents/skills/conventions-use-effect/SKILL.md and read by ahel’s review.

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:

  1. Can it be derived during render? Then derive it. (Rule 1)
  2. Is it data fetching? Use TanStack Query. (Rule 2)
  3. Does it happen in response to a user action? Do it in the event handler. (Rule 3)
  4. Does it reset state when an id/prop changes? Remount with key. (Rule 5)
  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

// ❌ extra render + loop hazard
const [filtered, setFiltered] = useState([]);
useEffect(() => setFiltered(products.filter((p) => p.inStock)), [products]);

// ✅ compute inline (React Compiler memoizes it)
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

// ❌ race conditions, hand-rolled caching
useEffect(() => { fetchProduct(id).then(setProduct); }, [id]);

// ✅ cancellation/caching/staleness handled for you
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

// ❌ effect as an action relay
useEffect(() => { if (liked) { postLike(); setLiked(false); } }, [liked]);

// ✅ do the work where the event happens
<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

// ✅ a new id gives a brand-new instance; mount logic runs once, cleanly
<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.

// Push zoom into the imperative folio editor whenever it changes.
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);
  /* eslint-disable react/refs -- render-time ref write closes the commit→effect race window for the stale-response guard */
  seededDataRef.current = data;
  /* eslint-enable react/refs */
}

const loadOlder = useCallback(async () => {
  const requestedData = seededDataRef.current;
  const older = await fetchOlderVersions(/* ... */);
  if (seededDataRef.current !== requestedData) {
    return; // superseded — a refetch or entity switch reseeded the page
  }
  setAccumulated((current) => [...current, ...older.versions]);
}, [/* ... */]);

In-repo exemplars: apps/web/src/components/inspector/versions-facet.tsx (seededDataRef) and apps/web/src/features/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:

// oxlint-disable-next-line no-raw-use-effect/no-raw-use-effect -- <why a wrapper does not fit>
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.

Signals

GitHub stars
237
Forks
51
Last commit
Sep 2026
Advanced
Catalog kind
skill
Gateway key
conventions-use-effect
Source
github.com/stella/stella