Skip to content

Commit af950ab

Browse files
orionmizclaude
andauthored
chore: strengthen purity and ref rules for Concurrent Mode (#694)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 3f68ddd commit af950ab

3 files changed

Lines changed: 76 additions & 0 deletions

File tree

.agents/skills/review-react/SKILL.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,22 @@ Reference these guidelines when:
6464
- `advanced-event-handler-refs` - Store latest event handlers in refs for stable callbacks
6565
- `advanced-init-once` - Initialize app-level singletons once, not per mount
6666

67+
## Review Discipline
68+
69+
### Never downgrade CRITICAL violations
70+
71+
When a CRITICAL rule violation is detected (e.g., `react-rules-purity`), **fix it — do not rationalize exceptions**. Common rationalizations to reject:
72+
73+
- "It's idempotent, so Strict Mode double-render is fine" — Strict Mode is not the only concern; Concurrent Mode render abandonment is the real danger.
74+
- "It works in practice" — Concurrent features may not be active today but the code must be correct when they are.
75+
- "Adding a comment explaining the intent is sufficient" — A comment does not prevent the bug. If the rule says "don't do X", the fix is to stop doing X.
76+
77+
If you detect a violation, move to "fix" before moving to "judge severity." The cost of a false positive (unnecessary refactor) is far lower than the cost of a false negative (shipping a Concurrent Mode bug).
78+
79+
### Re-check after refactors
80+
81+
When a fix for one issue changes the code structure (e.g., adding `callback` to useEffect deps), **re-run the full rule check** on the modified code. A fix for one rule can regress another — e.g., fixing `rerender-dependencies` can reintroduce a `react-rules-purity` violation if it moves code back into render phase.
82+
6783
## How to Use
6884

6985
Read individual rule files for detailed explanations and code examples:

.agents/skills/review-react/rules/advanced-event-handler-refs.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,3 +53,18 @@ function useWindowEvent(event: string, handler: (e) => void) {
5353
```
5454

5555
`useEffectEvent` provides a cleaner API for the same pattern: it creates a stable function reference that always calls the latest version of the handler.
56+
57+
### When external code reads your ref: useEffect update is mandatory
58+
59+
The "Correct" pattern above (update ref in `useEffect`) is not just an optimization — it becomes a **correctness requirement** when code outside React reads the ref synchronously.
60+
61+
Examples of external readers:
62+
- Store/state-manager subscribers (e.g., `store.subscribe()`, Zustand/Redux middleware)
63+
- Event bus listeners (e.g., `EventEmitter.on()`)
64+
- Framework lifecycle hooks that fire outside React's render cycle
65+
66+
If you update the ref during render instead of in useEffect, Concurrent Mode can abandon that render. The external reader then sees a callback from a render that never committed — a value that doesn't correspond to any real UI state.
67+
68+
**Rule of thumb:** If anything outside React's tree can call `ref.current`, the ref must only be written in `useEffect`. The one-render staleness between render and commit is the correct trade-off — a stale-but-committed callback is always safer than an uncommitted one.
69+
70+
See also: `react-rules-purity` Rule 6 for the full rationale and examples.

.agents/skills/review-react/rules/react-rules-purity.md

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,4 +118,49 @@ function Page({ title }) {
118118
}
119119
```
120120

121+
### Rule 6: Never write refs during render when external code reads them synchronously
122+
123+
Render-phase ref writes (`ref.current = value`) are dangerous when **code outside React** (store subscribers, event bus listeners, framework lifecycle hooks) can read that ref synchronously between renders.
124+
125+
In Concurrent Mode, React may **abandon** a render and start a new one with different props. If you wrote to the ref during the abandoned render, external readers see a value from a render that never committed — a value that doesn't correspond to any committed UI state.
126+
127+
This is **not** mitigated by the write being "idempotent" — the problem isn't double-writes from Strict Mode, it's writes from renders that are discarded entirely.
128+
129+
**Incorrect (render-phase ref write with external reader):**
130+
131+
```tsx
132+
function useEventBusHandler(bus: EventBus, event: string, handler: () => void) {
133+
const handlerRef = useRef(handler)
134+
handlerRef.current = handler // ← render phase write
135+
136+
useEffect(() => {
137+
// External subscriber reads handlerRef on every event
138+
return bus.on(event, () => handlerRef.current())
139+
}, [bus, event])
140+
}
141+
142+
// Meanwhile, outside React:
143+
// bus.emit() fires synchronously on state change — may read a ref
144+
// written by a render that React later abandoned
145+
```
146+
147+
**Correct (ref updated only in useEffect):**
148+
149+
```tsx
150+
function useEventBusHandler(bus: EventBus, event: string, handler: () => void) {
151+
const handlerRef = useRef(handler)
152+
useEffect(() => {
153+
handlerRef.current = handler // ← commit phase only
154+
})
155+
156+
useEffect(() => {
157+
return bus.on(event, () => handlerRef.current())
158+
}, [bus, event])
159+
}
160+
```
161+
162+
The trade-off: between render and commit, the subscriber may read a one-step-old handler. But a stale-but-committed value is always safer than a value from a render that never committed.
163+
164+
**Key principle:** Do not make assumptions about React's scheduling. "This render will commit before anything else happens" is never guaranteed in Concurrent Mode. Any optimization based on that assumption will break.
165+
121166
Reference: [Components and Hooks must be pure](https://react.dev/reference/rules/components-and-hooks-must-be-pure)

0 commit comments

Comments
 (0)