conventions-use-effect
GitHub规范 React useEffect 使用,禁止直接调用。提供决策顺序:优先渲染时派生、用 TanStack Query 获取数据、事件处理用户操作、用 key 重置状态,仅同步外部系统时使用受控包装器。
Trigger Scenarios
Install
npx skills add stella/stella --skill conventions-use-effect -g -y
SKILL.md
Frontmatter
{
"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
useMountEffectoruseExternalSyncEffect. (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/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:
// 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.
Version History
- dd81665 Current 2026-08-16 07:08
- 85792bd 2026-07-24 16:11


