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.
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
| Platform | Status | Evidence | What to check |
|---|---|---|---|
| Codex | Not declared | No explicit evidence | Portability before use |
| Claude Code | Not declared | No explicit evidence | Portability before use |
| Cursor | Not declared | No explicit evidence | Portability before use |
| Gemini CLI | Not declared | No explicit evidence | Portability before use |
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.
npx skills add https://github.com/stella/stella --skill ".agents/skills/conventions-use-effect"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
- 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) - 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. - 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. - 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"). - 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
| Signal | Value | Evidence type | Meaning |
|---|---|---|---|
| Quality score | 86/100 | Computed | Documentation, specificity, maintenance, and trust rules |
| Repository stars | 161 | Source | Repository attention, not individual Skill quality |
| Compatibility | 0 platforms | Source | Declared in the catalog source record |
| Usage guide | automated source guide | Editorial | Generated 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:
- 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.
Alternatives
Compare before choosing
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.
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.
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.
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.