Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
2e5c1c1
Add performance skills: measurement + React/Redux anti-pattern reviews
MajorLift Jun 5, 2026
faa92c6
Make knowledge/ the single source for the React perf pattern taxonomy
MajorLift Jul 30, 2026
fd9418e
Consolidate the shift-left performance work into one domain PR
MajorLift Jul 30, 2026
143a7c5
Restore the internal planning-ticket references
MajorLift Jul 30, 2026
3c23890
Take in the benchmarking skills
MajorLift Jul 30, 2026
69051e9
Point the extension overlays at `main`, not `develop`
MajorLift Jul 30, 2026
2d3741e
Shorten and normalise skill names
MajorLift Jul 31, 2026
c830f79
Rename the antipattern skills from `-review` to `-scan`
MajorLift Jul 31, 2026
576ae50
Name the evidence category in `react-render-proof` instead of indexin…
MajorLift Jul 31, 2026
81dad3b
Rename `react-render-proof` to `react-render-delta`
MajorLift Jul 31, 2026
8465707
Widen the §3 grep to catch result functions returning fresh literals
MajorLift Aug 1, 2026
9999888
Name the installed command in `react-render-delta`'s description
MajorLift Aug 4, 2026
8de7441
Move the measurement skills to #144, leaving the antipattern audit here
MajorLift Sep 1, 2026
6e18b75
Move React Compiler triage to #145 and drop internal ticket ids
MajorLift Sep 1, 2026
519c414
Treat Immer-produced controller state as reference-stable, and drop t…
MajorLift Sep 14, 2026
59ba9e4
Count one-span-per-transaction as fan-out, and bound what a kill-swit…
MajorLift Sep 14, 2026
82d0234
Name an effect dependency the effect never reads as a wrong dependency
MajorLift Sep 16, 2026
fd6d6ed
Merge remote-tracking branch 'origin/main' into jongsun/add/performance
MajorLift Sep 18, 2026
c854775
Merge branch 'main' into jongsun/add/performance
MajorLift Sep 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
170 changes: 170 additions & 0 deletions domains/performance/knowledge/effect-antipatterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
---
name: effect-antipatterns
domain: performance
description: The React `useEffect` patterns that cause unnecessary renders, memory leaks, or race conditions — the canonical, platform-agnostic taxonomy that per-repo effect references instantiate
---

# Effect Anti-Patterns

**This file is the single source for the pattern taxonomy.** Per-repo references — such as
the `mm-hook-dependency-arrays` and `mm-useeffect-antipatterns` references shipped with the
`performance` skill — name these patterns rather than redefining them, and add what only
they can: verified instances with `file:line`, repo-specific lint gaps, and fix recipes.

Two halves, and they fail differently. Patterns 1–2 are about **when an effect re-runs**
(the dependency side). Patterns 3–5 are about **what happens inside and after it** (the
lifecycle side).

## 1. Unstable dependency identity

A dependency array is supposed to be a cheap identity check. Anything that produces a new
value every render defeats it — and usually signals an unstable reference upstream.

```typescript
// ❌ serializes on EVERY render just to build the dep key
useEffect(() => { doSomething(config) }, [JSON.stringify(config)])

// ❌ new object every render → effect runs every render (or loops forever)
useEffect(() => { ... }, [{ id: user.id }])

// ✅ stabilize the reference upstream, then depend on it directly
const stableConfig = useMemo(() => derive(a, b), [a, b])
useEffect(() => { doSomething(stableConfig) }, [stableConfig])

// ✅ or depend on the primitives
useEffect(() => { ... }, [user.id])
```

Stabilizing the source beats hashing it. If you genuinely cannot, a primitive key computed
**once** (`useMemo(() => xs.join(','), [xs])`) still beats a per-render `JSON.stringify`.

Detection: grep for `JSON.stringify` inside a dependency array, and for inline `{`/`[`
literals in the dep position.

## 2. Wrong dependencies

```typescript
// ❌ empty deps but reads state → stale closure, value frozen at first render
const onPress = useCallback(() => doThing(count), [])

// ❌ empty deps and reads nothing → this was never a hook, hoist it out
const config = useMemo(() => ({ a: 1, b: 2 }), [])

// ❌ lists a value the effect never reads → it restarts on networkId with nothing using it
useEffect(() => {
const session = startSession(address)
return () => session.stop()
}, [address, networkId])

// ✅ the session is for one network, so the effect passes the network in
useEffect(() => {
const session = startSession({ address, networkId })
return () => session.stop()
}, [address, networkId])
```

**Fix:** include what you read, and read what you include. If there is genuinely nothing to
read, move the constant outside the component. If an effect must restart when a value
changes, the effect has to use that value: pass it into the work the effect starts, or
render the scope that must reset as a component keyed on the value
([Resetting all state when a prop changes](https://react.dev/learn/you-might-not-need-an-effect#resetting-all-state-when-a-prop-changes)).
Deleting the dependency leaves the effect bound to the old value. A comment beside it, or
`'use no memo'`, leaves it unread, and React Compiler's effect-dependency validation (off by
default) reports it.

Where `react-hooks/exhaustive-deps` is not enabled, the first two cases are not caught
automatically. The third is never caught by it: the rule accepts an extra effect
dependency that is a component value, so it must be reviewed by hand.

## 3. Derived state via effect + setState

If a value is computable from props/state/store, compute it during render. State plus an
effect is for *synchronizing with something external*, not for derivation.

```typescript
// ❌ two render passes per change: render → effect → setState → render again
const [visible, setVisible] = useState([])
useEffect(() => { setVisible(items.filter((t) => !t.hidden)) }, [items])

// ✅ derive during render — one pass, no state to drift out of sync
const visible = useMemo(() => items.filter((t) => !t.hidden), [items])
```

The React docs call this out directly:
[You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect).

### 3a. Cascading effect chains

The same mistake compounded: effect A sets state, which triggers effect B, which sets
state, which triggers effect C. Each link is a full extra render pass *and* a window where
the UI shows an inconsistent intermediate combination.

**Fix:** collapse the chain into render-time derivation — one `useMemo` per step, or one
for the lot.

## 4. Missing timer cleanup

Every `setInterval` and recurring `setTimeout` started in an effect must be cleared in its
cleanup. Otherwise the timer outlives unmount, fires against dead state, and leaks in
proportion to how often the component mounts.

```typescript
// ❌ BROKEN: timer leaks after unmount
useEffect(() => { setInterval(poll, 1000) }, [])

// ✅ FIXED
useEffect(() => {
const id = setInterval(poll, 1000)
return () => clearInterval(id)
}, [])
```

## 5. Uncancelled async work

Async work started in an effect can resolve *after* unmount — or after the input changed,
letting a stale response overwrite a newer one.

```typescript
// ❌ fetch races unmount; stale data can win
useEffect(() => { fetchMeta(address).then(setMeta) }, [address])

// ✅ cancelled flag — cheapest, works for any promise
useEffect(() => {
let cancelled = false
fetchMeta(address).then((m) => { if (!cancelled) setMeta(m) })
return () => { cancelled = true }
}, [address])

// ✅ AbortController — also cancels the request itself
useEffect(() => {
const ctrl = new AbortController()
fetch(url, { signal: ctrl.signal })
.then((r) => setData(r))
.catch((e) => { if (e.name !== 'AbortError') throw e })
return () => ctrl.abort()
}, [url])
```

Pick one and apply it consistently.

## Why these matter

- **Renders.** Derived-state effects double every render in the affected subtree, and
chains multiply it.
- **Memory.** Uncleared timers and subscriptions leak proportional to mount count.
- **Correctness.** Uncancelled async work produces "state update on an unmounted
component" warnings and, worse, races where an older response overwrites a newer one.

## Don't over-correct

- Don't add `useMemo`/`useCallback` everywhere — only where profiling shows wasted work, or
where a memoized child depends on the reference. Compilers handle many cases on opted-in
paths.
- A `JSON.stringify` on a cold path with a small object is acceptable. Prioritize hot render
paths.

## Related

- `render-cascade` — how effect-driven re-renders propagate through the component graph.
- `selector-antipatterns` — the store-side counterpart; an unstable selector result is a
common source of the unstable dependency in pattern 1.
68 changes: 68 additions & 0 deletions domains/performance/knowledge/render-cascade.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
---
name: render-cascade
domain: performance
description: React+Redux render cascade failure mode — single state change triggers multiple re-render cycles
---

# Render Cascade

Single state change → broken selector returns new reference → `useSelector` detects "change" → parent re-renders all children → children trigger more selectors → cycle repeats 5+ times before stabilizing.

## Cost Scaling

| Factor | Impact |
|--------|--------|
| Component tree depth | Each level multiplies re-renders |
| User data size | O(n) selectors × n items = O(n²) operations |
| State update frequency | Background polling compounds the problem |

Power users (large datasets, many accounts/tokens/transactions) are disproportionately affected.

## Root Causes

| Cause | Pattern | Fix |
|-------|---------|-----|
| Plain function selector | `export function get...` | Wrap in `createSelector` |
| Identity function selector | Transform in input, identity in result | Move transform to result function |
| Unnecessary deep equality | `createDeepEqualSelector` on stable Immer inputs | Use `createSelector` |
| O(n) lookup | `.find()` in selector | Normalize state to map; use direct access |
| Chained transforms (unmemoized) | Multiple `.map`/`.filter` in plain function | Single `createSelector` with all transforms |
| Context provider instability | `<Context.Provider value={{ a, b }}>` inline | `useMemo` the value |
| Props recreation | `useParams()` passed directly as prop | `useMemo` the props object |

## Selector Creator Decision Tree

```
Is INPUT unstable (not from Immer/Redux)?
├── YES → createDeepEqualSelector
└── NO → Is OUTPUT unstable (new array/object from transform)?
├── YES → createResultEqualSelector (or createShallowResultSelector)
└── NO → createSelector
```

## Fix Order — Root Selectors First

Selectors form a dependency graph. When a root selector returns an unstable reference, the cost cascades:

- recomputations: **O(m)** — all m dependent selectors recompute
- cascade depth: **O(log m)** — propagates through the tree
- re-renders: **O(m × k)** — each selector triggers k subscribers

**Fixing downstream selectors is ineffective until the upstream root is stable** — a fixed `getActiveAccount` still receives a new input every render if `getAccounts` is broken.

```
getAccountsObject (stable)
└─ getAccounts (broken: returns new array)
├─ getActiveAccount ├─ getAccountCount └─ getAccountNames …
```

Triage the dependency graph top-down; fix roots first.

## Why Cascade Breaks All Other Optimizations

| Optimization | Without Cascade Fix | With Cascade Fix |
|---|---|---|
| Virtualization | Parent still re-renders all | Works as intended |
| `React.memo` | Parent defeats it | Works as intended |
| React Compiler | Can't cross file boundaries | Complements selectors |
| `useMemo`/`useCallback` | Recreated on parent render | Stable references |
Loading
Loading