Skip to content

Commit 8c71280

Browse files
committed
chore: Update React development guidelines
This file is automatically synced from the `shared-configs` repository. Source: https://github.com/doist/shared-configs/blob/main/
1 parent 7aaad9d commit 8c71280

1 file changed

Lines changed: 67 additions & 0 deletions

File tree

docs/react-guidelines/state.md

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,70 @@ export function useCurrentViewConfig() {
220220
}
221221
```
222222

223+
## Render Purity and Local State
224+
225+
### Don't read external state during render
226+
227+
React components [must be idempotent](https://react.dev/reference/rules/components-and-hooks-must-be-pure) - they should always return the same output with respect to their inputs. Reading from external sources like `localStorage`, `sessionStorage`, or browser APIs during render violates this because they are mutable sources outside React's control (same category as `Date.now()` or `Math.random()`). Beyond purity, these reads also create new references on every call, which causes render loops and stale-value bugs.
228+
229+
```typescript
230+
// Bad: reads localStorage on every render, creates new arrays each time
231+
function useActivityFilters(urlParams: URLSearchParams) {
232+
const presets = getLocalStorageValue<string[]>('filterPresets') ?? []
233+
const types = urlParams.get('types')?.split(',') ?? presets
234+
return { types } // New object + new array every render
235+
}
236+
237+
// Good: read external state once via useState initializer
238+
function useActivityFilters(urlParams: URLSearchParams) {
239+
const [initialPresets] = useState(() => getLocalStorageValue<string[]>('filterPresets') ?? [])
240+
// Use the stable string as the dependency, derive the array inside useMemo
241+
const typesParam = urlParams.get('types')
242+
return useMemo(
243+
() => ({ types: typesParam?.split(',') ?? initialPresets }),
244+
[typesParam, initialPresets],
245+
)
246+
}
247+
```
248+
249+
### `useState` initializer vs `useMemo` for one-time computations
250+
251+
When you need to compute something once on mount and never recompute it, `useState(() => ...)` is the right tool - the [initializer function runs only on the first render](https://react.dev/reference/react/useState#avoiding-recreating-the-initial-state). `useMemo` recalculates when dependencies get new references, which can trigger the very re-renders you're trying to avoid. The lazy initializer runs once, freezes the result, and is immune to reference instability.
252+
253+
```typescript
254+
// Good: computed once, stable forever
255+
const [initialFilters] = useState(() => deriveFiltersFromURL(searchParams))
256+
257+
// Risky: recalculates if searchParams reference changes
258+
const initialFilters = useMemo(() => deriveFiltersFromURL(searchParams), [searchParams])
259+
```
260+
261+
Use `useState(() => ...)` when the value should be pinned to mount time. For reactive derived values, the [React Compiler](react-compiler.md) handles memoization automatically in compiler-enabled code; only reach for manual `useMemo` when the compiler is not active.
262+
263+
See also: [useState lazy initialization](react-compiler.md#alternative-usestate-lazy-initialization) in the React Compiler guide for the compiler-specific motivation.
264+
265+
### Stable reducer sentinel values
266+
267+
React [skips re-rendering when the new state is identical to the current state](https://react.dev/reference/react/useReducer#dispatch) via `Object.is` comparison. If a reducer returns a new object with the same shape on every dispatch, that check fails and React re-renders. Hoisting fixed sentinel states to module scope ensures the same reference is returned, letting React bail out.
268+
269+
This is only worth investigating if profiling shows unnecessary re-renders from a reducer - React's render batching already handles most cases. Only use sentinels for truly fixed values; states that carry variable data (like errors with messages) need a new object each time.
270+
271+
```typescript
272+
const loadingState = { status: 'loading' as const, data: null, error: null }
273+
274+
function fetchReducer(state: FetchState, action: FetchAction): FetchState {
275+
switch (action.type) {
276+
case 'FETCH_START':
277+
return loadingState // Same reference — React bails out of re-render
278+
case 'FETCH_ERROR':
279+
// New object is correct here — error carries variable data
280+
return { status: 'error', data: null, error: action.error }
281+
case 'FETCH_SUCCESS':
282+
return { status: 'success', data: action.payload, error: null }
283+
}
284+
}
285+
```
286+
223287
## Rules
224288

225289
- **Never import `useDispatch` / `useSelector` from `react-redux`** - Use `useAppDispatch` / `useAppSelector`
@@ -230,3 +294,6 @@ export function useCurrentViewConfig() {
230294
- **Selectors must return stable references** - Unstable selectors cause infinite loops in Zustand v5 and wasted renders in Redux; hoist fallback values to module scope
231295
- **Use `useShallow` for multi-value Zustand selectors** - Prefer atomic selectors, but when multiple values are always consumed together, `useShallow` prevents reference instability
232296
- **Don't suppress dev-mode stability checks** - Reselect and React-Redux stability warnings catch real bugs; fix the root cause instead
297+
- **Don't read external state during render** - `localStorage`, `sessionStorage`, and browser APIs produce unstable references; read once via `useState(() => ...)` or in effects
298+
- **Use `useState` initializer for mount-time values** - `useState(() => ...)` runs once and is immune to reference instability. For reactive derived values, the [React Compiler](react-compiler.md) handles memoization automatically in compiler-enabled code; only reach for `useMemo` when the compiler is not active
299+
- **Consider stable reducer sentinels when profiling shows wasted renders** - Hoisting fixed states (loading) to module scope lets `useReducer` return the same reference so React can bail out; only worth doing when the sub-tree is expensive

0 commit comments

Comments
 (0)