Apply when writing or reviewing React effects in apps/web. Direct useEffect is banned; use the sanctioned wrappers or a better primitive.
Scanned 9/3/2026
Install to Claude Code
npx -y skills add stella/stella --skill conventions-use-effect --agent claude-codeInstalls into .claude/skills of the current project.
Are you the author of Conventions Use Effect?
Add the live security badge to your README — it updates automatically with every re-scan.
[](https://www.skillsdirectory.com/skills/stella-conventions-use-effect-stella)More formats (shields.io, HTML) on the badges page.
---
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](https://react.dev/learn/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
```tsx
// ❌ 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
```tsx
// ❌ 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
```tsx
// ❌ 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
```tsx
// ✅ 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.
```tsx
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.
```tsx
// 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:
```tsx
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:
```tsx
const seededDataRef = useRef(data);
if (data !== undefined && seededData !== data) {
setSeededData(data);
/* eslint-disable react/react-compiler -- render-time ref write closes the commit→effect race window for the stale-response guard */
seededDataRef.current = data;
/* eslint-enable react/react-compiler */
}
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:
```tsx
// 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.
Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.
No comments yet. Be the first to comment!