Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
149 changes: 149 additions & 0 deletions domains/performance/knowledge/effect-antipatterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
---
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 }), [])
```

**Fix:** include what you read; or if there is genuinely nothing to read, move the constant
outside the component. Where `react-hooks/exhaustive-deps` is not enabled, this is not
caught automatically and 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 |
163 changes: 163 additions & 0 deletions domains/performance/knowledge/selector-antipatterns.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
---
name: selector-antipatterns
domain: performance
description: The Redux selector patterns that break memoization and cause render cascades — the canonical, platform-agnostic taxonomy that per-repo selector references instantiate
---

# Selector Anti-Patterns

**This file is the single source for the pattern taxonomy.** Per-repo references — such as
the `mm-selector-memoization` reference shipped with the `performance` skill — name these
patterns rather than redefining them, and add what only they can: the codebase's own
selector-creator utilities, verified instances with `file:line`, and fix recipes.

Every pattern below has the same failure shape: `useSelector` returns a **new reference**
when the underlying data did not change, so every consumer re-renders. One broken selector
near the root of the graph cascades through everything downstream, and the cost scales
superlinearly with user data.

## 1. Unmemoized selector

A plain function that allocates. No memoization at all — a new reference on every call.

```typescript
// ❌ BROKEN
export function getPendingApprovals(state) {
return Object.values(state.pendingApprovals ?? {});
}

// ✅ FIXED
const getPendingApprovalsObject = (state) => state.pendingApprovals ?? {};
export const getPendingApprovals = createSelector(
getPendingApprovalsObject,
(approvals) => Object.values(approvals),
);
```

Detection: grep exported `function get…` in the selectors directory.

## 2. Identity / passthrough result

The transform happens in the **input** and the result function returns its input unchanged,
so the cache can never hit. A plain `createSelector` only helps when its *inputs* are
reference-stable, and an input selector that allocates on every call never is.

```typescript
// ❌ BROKEN: Object.values() in the INPUT creates a new array each call
export const getAccounts = createSelector(
(state) => Object.values(state.accounts),
(accounts) => accounts, // identity — cache never hits
);

// ✅ FIXED: stable input, transform in the OUTPUT
export const getAccounts = createSelector(
(state) => state.accounts, // stable structural reference
(accounts) => Object.values(accounts),
);
```

Detection: the reselect/Jest warning `"result function returned its own inputs"`.

## 3. New collection allocated in the result function

Even a correctly-shaped `createSelector` returns a new reference whenever it recomputes —
and if its inputs are unstable, that is every dispatch.

```typescript
// ❌ new array/Set/Map/object every call → always "changed"
(accounts) => Object.values(accounts).sort(...)
(transactions) => new Set(transactions.flatMap(...))
(items) => items.filter(...)
(state) => state.swapsTransactions ?? {} // a fresh {} on every nullish hit
```

**Fix:** a deep-equal selector creator (returns the *cached* reference when data is
unchanged), a stable module-level constant for the empty case, or a result-equality check.

## 4. Mutation in the result function

```typescript
// ❌ mutates the input array AND returns a corrupting reference
createSelector([getItems], (items) => { items.sort(cmp); return items; })
```

**Fix:** copy first — `[...items].sort(cmp)`.

## 5. Over-broad input

`state => state`, or a large slice, as an input selector forces recomputation on **any**
state change anywhere. Narrow the input to the smallest slice that actually feeds the
result.

## 6. Unnecessary deep equality

Deep-equal creators cost O(n) per comparison. Reaching for one when the input is already
reference-stable pays that cost for nothing — and deep-comparing a large slice on every
dispatch can be worse than the re-render it prevents.

```typescript
// ❌ UNNECESSARY: this slice is already reference-stable
const getAccounts = createDeepEqualSelector(
(state) => state.accounts,
(accounts) => transformAccounts(accounts),
);
```

Prefer **narrowing the input** over deep-equalizing a giant object.

## 7. O(n) lookups over unnormalized state

`.find()` over `Object.values()` is O(n). With n items × m selectors per state change that
is O(n×m) on every dispatch.

```typescript
// ❌ BROKEN
export const getAccountByAddress = (state, address) =>
Object.values(state.accounts).find((a) => a.address === address);

// ✅ FIXED: normalized state, O(1) access
export const getAccountByAddress = (state, address) => state.accounts[address];
```

## 8. Chained unmemoized transforms

Each transform allocates. Several in sequence means several new references per call.

```typescript
// ❌ BROKEN: 3 new arrays per call
export function getSortedItems(state) {
const items = Object.values(state.items); // array 1
const filtered = items.filter(isVisible); // array 2
return filtered.sort(byDate); // array 3
}

// ✅ FIXED: single memoized output
export const getSortedItems = createSelector(
(state) => state.items,
getFilterCriteria,
(items, criteria) =>
Object.values(items).filter((i) => matchesCriteria(i, criteria)).sort(byDate),
);
```

## Selector creator decision tree

```
Is the INPUT unstable (a fresh object/array every dispatch)?
├── YES → deep-equal selector creator (but prefer narrowing the input first)
└── NO → Is the OUTPUT unstable (a new array/object from the transform)?
├── YES → result-equality selector creator
└── NO → plain createSelector
```

## Don't over-correct

- A selector returning a **primitive** is fine even if it filters internally — the consumer
memoizes on the primitive value. Wasteful allocation, not a re-render bug.
- Memoization is not free. Prefer narrowing inputs over adding comparison work.

## Related

- `render-cascade` — what one broken root selector does to the component graph downstream.
- Per-repo instances: the `mm-selector-memoization` reference documents a codebase's own
selector creators, its verified broken selectors, and the fix recipe for each.
Loading