Agent Skillsstella/stella › conventions-use-effect

conventions-use-effect

GitHub

规范 React useEffect 使用,禁止直接调用,引导通过派生、TanStack Query、事件处理或专用封装器替代。

.ai/local-skills/conventions-use-effect/SKILL.md stella/stella

Trigger Scenarios

编写 React 组件代码 审查前端代码中的副作用逻辑

Install

npx skills add stella/stella --skill conventions-use-effect -g -y
More Options

Non-standard path

npx skills add https://github.com/stella/stella/tree/main/.ai/local-skills/conventions-use-effect -g -y

Use without installing

npx skills use stella/stella@conventions-use-effect

指定 Agent (Claude Code)

npx skills add stella/stella --skill conventions-use-effect -a claude-code -g -y

安装 repo 全部 skill

npx skills add stella/stella --all -g -y

预览 repo 内 skill

npx skills add stella/stella --list

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:

  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/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:09
  • 85792bd 2026-07-24 16:12

Same Skill Collection

.agents/skills/click-around/SKILL.md
.agents/skills/conventions-ai/SKILL.md
.agents/skills/conventions-db/SKILL.md
.agents/skills/conventions-i18n/SKILL.md
.agents/skills/conventions-ingestion/SKILL.md
.agents/skills/conventions-perf/SKILL.md
.agents/skills/conventions-scale/SKILL.md
.agents/skills/conventions-security/SKILL.md
.agents/skills/conventions-use-effect/SKILL.md
.agents/skills/conventions-ux/SKILL.md
.agents/skills/dev/SKILL.md
.agents/skills/finish-pr/SKILL.md
.agents/skills/new-handler/SKILL.md
.agents/skills/open-pr/SKILL.md
.agents/skills/plan/SKILL.md
.agents/skills/product-deep-think/SKILL.md
.agents/skills/product-think/SKILL.md
.agents/skills/rabbit-round/SKILL.md
.agents/skills/regression-hunt/SKILL.md
.agents/skills/security-audit/SKILL.md
.agents/skills/update-deps/SKILL.md
.ai/local-skills/click-around/SKILL.md
.ai/local-skills/conventions-ai/SKILL.md
.ai/local-skills/conventions-db/SKILL.md
.ai/local-skills/conventions-i18n/SKILL.md
.ai/local-skills/conventions-ingestion/SKILL.md
.ai/local-skills/conventions-perf/SKILL.md
.ai/local-skills/conventions-scale/SKILL.md
.ai/local-skills/conventions-security/SKILL.md
.ai/local-skills/conventions-ux/SKILL.md
.ai/local-skills/dev/SKILL.md
.ai/local-skills/new-handler/SKILL.md
.ai/local-skills/open-pr/SKILL.md
.ai/local-skills/plan/SKILL.md
.ai/local-skills/product-deep-think/SKILL.md
.ai/local-skills/rabbit-round/SKILL.md
.ai/local-skills/security-audit/SKILL.md
.ai/local-skills/update-deps/SKILL.md
packages/cli/skills/stella-cli/SKILL.md
packages/skills/blueprints/answer-from-sources/SKILL.md
packages/skills/blueprints/blank/SKILL.md
packages/skills/blueprints/check-against-rules/SKILL.md
packages/skills/blueprints/intake-to-draft/SKILL.md
.agents/skills/conventions-testing/SKILL.md
.ai/local-skills/conventions-testing/SKILL.md

Metadata

Files
0
Version
dd81665
Hash
a69569cc
Indexed
2026-07-24 16:12

inicio - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-08-16 19:42
浙ICP备14020137号-1 $mapa de visitantes$