Source profileQuality 86/100

stella/stella/.agents/skills/conventions-use-effect/SKILL.md

conventions-use-effect

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

Source repository stars
161
Declared platforms
0
Static risk flags
0
Last source update
2026-08-04
Source checked
2026-08-04

Decision brief

What it does—and where it fits

Apply when writing or reviewing React code in apps/web.

Best for

    Not for

    • Tasks that require unconfirmed production actions or broad system permissions.
    • Environments where the pinned source and install steps cannot be inspected.

    Compatibility matrix

    Platform support, with evidence labels

    PlatformStatusEvidenceWhat to check
    CodexNot declaredNo explicit evidencePortability before use
    Claude CodeNot declaredNo explicit evidencePortability before use
    CursorNot declaredNo explicit evidencePortability before use
    Gemini CLINot declaredNo explicit evidencePortability before use
    Open the compatibility checker

    Installation

    Inspect first. Install second.

    The source command is displayed only when detected. A safe inspection prompt is always available so your agent can explain every action before execution.

    Source-detected install commandSource
    npx skills add https://github.com/stella/stella --skill ".agents/skills/conventions-use-effect"
    Safe inspection promptEditorial

    Inspect the Agent Skill "conventions-use-effect" from https://github.com/stella/stella/blob/e30339d2f0178390f18059145b03651e6b244c4b/.agents/skills/conventions-use-effect/SKILL.md at commit e30339d2f0178390f18059145b03651e6b244c4b. List every install step, command, network request, credential, file read/write, external action, and rollback step. Explain whether it fits my task. Do not install or execute anything until I approve.

    Workflow

    What the source asks the agent to do

    1. 01

      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)
    2. 02

      Rule 1 — derive state, do not sync it

      Smell: useEffect(() = setX(deriveFrom(y)), [y]), or state that only mirrors other state/props.

      Smell: useEffect(() = setX(deriveFrom(y)), [y]), or state that only mirrors other state/props.
    3. 03

      Rule 2 — data-fetching library, not an effect

      Smell: an effect that does fetch(...) then setState(...), or re-implements retries/cancellation/staleness.

      Smell: an effect that does fetch(...) then setState(...), or re-implements retries/cancellation/staleness.
    4. 04

      Rule 3 — event handlers, not effects

      Smell: state used as a flag so an effect can run the real action ("set flag → effect runs → reset flag").

      Smell: state used as a flag so an effect can run the real action ("set flag → effect runs → reset flag").
    5. 05

      Rule 5 — reset with key, not dependency choreography

      Smell: an effect whose only job is to reset local state when an id changes.

      Smell: an effect whose only job is to reset local state when an id changes.

    Permission review

    Static risk signals and limitations

    No configured static risk pattern was detected

    This is not proof of safety. Runtime behavior, indirect dependencies, and hidden external systems are outside the static scan.

    Evidence record

    Why each signal appears

    EvidenceSourceComputedTestedEditorial
    SignalValueEvidence typeMeaning
    Quality score86/100ComputedDocumentation, specificity, maintenance, and trust rules
    Repository stars161SourceRepository attention, not individual Skill quality
    Compatibility0 platformsSourceDeclared in the catalog source record
    Usage guideautomated source guideEditorialGenerated or reviewed according to the visible evidence level

    Pinned source

    Provenance and original SKILL.md

    Repository
    stella/stella
    Skill path
    .agents/skills/conventions-use-effect/SKILL.md
    Commit
    e30339d2f0178390f18059145b03651e6b244c4b
    License
    Apache-2.0
    Collected
    2026-08-04
    Default branch
    main
    View the original SKILL.md

    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.

    Alternatives

    Compare before choosing

    Computed 1007

    event4u-app/agent-config

    existing-ui-audit

    Use BEFORE writing or editing any non-trivial UI — inventories components, design tokens, shadcn primitives, and reusable patterns into state.ui_audit. Hard gate for the ui directive set.

    Computed 95237,532

    affaan-m/ECC

    motion-advanced

    Advanced motion patterns for React / Next.js — drag & drop, gestures, text animations, SVG path drawing, custom hooks, imperative sequences (useAnimate), loaders, and the full API decision tree. Requires motion-foundations.

    Computed 9510,044

    ConardLi/garden-skills

    web-design-engineer

    Build or redesign polished browser-rendered visual artifacts with HTML/CSS/JavaScript/React: pages, dashboards, prototypes, slide decks, animations, UI mockups, and data visualizations. Use for visual front-end creation, design-system exploration, design critique, or explicit browser acceptance / QA of a web artifact. Not for back-end, CLI, non-visual coding, source-to-longform article conversion, or narration-driven click-through video presentations.

    Computed 95106

    AI-Unified-Process/marketplace

    vitest-test

    Creates Vitest component tests for Angular views using Angular's own testing idioms — TestBed, ComponentFixture, and HttpTestingController — not React Testing Library patterns. Use when the user asks to "write frontend tests", "test the Angular component", "write a Vitest test", "unit test an Angular page", or mentions TestBed, HttpTestingController, or component testing for this stack.