Skip to content

Commit 5da3dab

Browse files
committed
chore(rules): document render-phase ref-seed choice + no-render-in-render caveat
Two recurring pitfalls surfaced by the /w react-doctor pass: - sim-hooks.md: when converting a mount-running useEffect to the prevX render-phase idiom, the ref SEED decides mount behavior — sentinel vs current value. Also: only convert useState->useRef when the value is never read in render and never a hook dependency. - sim-components.md: no-render-in-render is usually a false positive for inline-called helpers (reconciled by position, no remount); extracting is churn/risk unless mechanical.
1 parent 76537c8 commit 5da3dab

2 files changed

Lines changed: 5 additions & 0 deletions

File tree

.claude/rules/sim-components.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,5 +28,6 @@ react-doctor diagnostics are hypotheses, not verdicts — confirm against the co
2828
- `no-barrel-import` — barrel imports are the repo convention (see sim-imports.md, "Barrel Exports"). Keep them.
2929
- `js-tosorted-immutable` — in `'use client'` code, keep `[...arr].sort(cmp)`; `toSorted` is unpolyfilled and crashes Safari <16 / iOS 15 (see "List-render performance" above). Only apply it in server-only modules.
3030
- `rerender-state-only-in-handlers` / "state set but never rendered" — a false positive when the `useState` is consumed by a `useEffect`/`useLayoutEffect` dependency (the effect must re-run on change). Only convert to a ref when nothing reads the value reactively.
31+
- `no-render-in-render` — a helper *called inline* (`{renderRow()}`) is reconciled by position and does **not** remount, so extracting it to a component is usually pure churn and can regress behavior (prop-drilling many closures, focus/scroll loss on the inner `<input>`). Apply it only when the helper is genuinely a *component defined during render*, or when the move is mechanical (a stateless, ref-free helper whose closures become a small, explicit prop set).
3132
- `async-await-in-loop` on an upload/progress loop where sequential execution is intentional (per-item progress, server backpressure) — leave it.
3233
- Broad refactors (`prefer-useReducer` for many `useState`, `no-giant-component` splits) — out of scope for a perf pass; note, don't churn.

.claude/rules/sim-hooks.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,10 @@ if (prevOpenRef.current !== open) {
6161
}
6262
```
6363

64+
**The ref seed decides mount behavior — choose it deliberately.** The example above seeds `useRef(open)` because the modal mounts closed, so the first render's guard is `false` and nothing resets on mount (correct — `name` is already at its initial value). But when the effect you're replacing did real work **on mount** — opening a panel because a prop already matches, seeding editable state from an already-present value, or a component that can mount in the active state — seed a **sentinel** instead (`useRef<T | null>(null)`), otherwise the guard is `false` on the first render and that mount action is silently dropped. Always update the ref **unconditionally** (so `A→B→A` / close→reopen transitions re-fire) and place the block before any early `return`.
65+
66+
Only convert a `useState` to a `useRef` when the value is **never read during render/JSX and is never a hook dependency** — a value in a `useEffect`/`useMemo`/`useCallback` dep array must re-run the hook on change, so it stays state (see also `rerender-state-only-in-handlers` in `sim-components.md`). Convert only set-only values read solely inside handlers or effect bodies (e.g. a prompt-history index, a pending-upload URL). If a ref feeds render, mutating it won't re-render and the UI goes stale.
67+
6468
Model mutually-exclusive flags as ONE `status` enum, not several contradictory booleans. `isLoading`/`isVerified`/`isInvalidOtp` describing one machine collapse to `status: 'idle' | 'verifying' | 'verified' | 'error'` (+ `errorMessage`); derive any boolean a consumer still needs (`status === 'error'`).
6569

6670
Derive busy/success from the mutation object — never duplicate `mutation.isPending`/`mutation.isSuccess` into local `useState`. Read them directly (`mutation.isSuccess`) and reset with `mutation.reset()`. A distinct phase the mutation doesn't cover — e.g. a pre-submit captcha/Turnstile gate that runs before `mutate()` — is not a duplicate; keep that flag.

0 commit comments

Comments
 (0)