Skip to content

Commit cb4d7ad

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 3aee335 commit cb4d7ad

1 file changed

Lines changed: 71 additions & 0 deletions

File tree

docs/react-guidelines/state.md

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,74 @@ 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 might create new references on every call (if you create new objects like parsing JSON or creating a new `Date`), which cause stale-value bugs and can contribute to unnecessary re-renders in children.
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+
If your project has a storage-aware hook like `useLocalStorageState`, prefer it over rolling the pattern by hand - they typically wrap the initial read in `useState(() => ...)`, sync writes back to storage, and re-render the component when the value in storage changes, so you get stable references for free.
250+
251+
### `useState` initializer vs `useMemo` for one-time computations
252+
253+
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.
254+
255+
```typescript
256+
// Good: computed once, stable forever
257+
const [initialFilters] = useState(() => deriveFiltersFromURL(searchParams))
258+
259+
// Risky: recalculates if searchParams reference changes
260+
const initialFilters = useMemo(() => deriveFiltersFromURL(searchParams), [searchParams])
261+
```
262+
263+
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.
264+
265+
See also: [useState lazy initialization](react-compiler.md#alternative-usestate-lazy-initialization) in the React Compiler guide for the compiler-specific motivation.
266+
267+
### Stable reducer sentinel values
268+
269+
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.
270+
271+
Combined with a [render-time read of external state](#dont-read-external-state-during-render), an unstable reducer result is enough to close a render loop: dispatch → reducer returns a new ref → re-render → render-read produces new refs → effect re-fires → dispatch again. Fixing one end stops the loop, but it's worth fixing both: defense-in-depth against a regression on either side.
272+
273+
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.
274+
275+
```typescript
276+
const loadingState = { status: 'loading' as const, data: null, error: null }
277+
278+
function fetchReducer(state: FetchState, action: FetchAction): FetchState {
279+
switch (action.type) {
280+
case 'FETCH_START':
281+
return loadingState // Same reference — React bails out of re-render
282+
case 'FETCH_ERROR':
283+
// New object is correct here — error carries variable data
284+
return { status: 'error', data: null, error: action.error }
285+
case 'FETCH_SUCCESS':
286+
return { status: 'success', data: action.payload, error: null }
287+
}
288+
}
289+
```
290+
223291
## Rules
224292

225293
- **Never import `useDispatch` / `useSelector` from `react-redux`** - Use `useAppDispatch` / `useAppSelector`
@@ -230,3 +298,6 @@ export function useCurrentViewConfig() {
230298
- **Selectors must return stable references** - Unstable selectors cause infinite loops in Zustand v5 and wasted renders in Redux; hoist fallback values to module scope
231299
- **Use `useShallow` for multi-value Zustand selectors** - Prefer atomic selectors, but when multiple values are always consumed together, `useShallow` prevents reference instability
232300
- **Don't suppress dev-mode stability checks** - Reselect and React-Redux stability warnings catch real bugs; fix the root cause instead
301+
- **Don't read external state during render** - `localStorage`, `sessionStorage`, and browser APIs produce unstable references; read once via `useState(() => ...)` or in effects
302+
- **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
303+
- **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)