skip to content

Why does a Redux todo list re-render every TodoItem on each dispatch when selectVisibleTodos filters state.todos, and how does Reselect fix it?

level: seniorimportance: must knowfreq 54%

answer

  1. filter always builds a new array
  2. reference comparison sees a change
  3. memoise on todos and the filter
  4. development warnings name the mistake

basics

~20 s

A plain selector calling filter returns a new array every call, so after any dispatch the list sees a changed value and re-renders every item. createSelector returns the cached array until todos or the filter actually change.

solid answer

~40 s

`const selectVisibleTodos = (state) => state.todos.filter(...)` builds a **new array** every time it runs. React-Redux's `useSelector` compares results by reference, so after **every** dispatch — even one that only changes an unrelated slice — the list component gets a different array, re-renders, and re-renders every `TodoItem` it maps over. The fix is `createSelector([selectTodos, selectFilter], (todos, filter) => todos.filter(...))`: the filter now runs only when `state.todos` or the filter value changes reference, and otherwise the same array is returned. Two mistakes keep the bug alive: filtering inside an **input selector**, and calling `createSelector` **inside the component**, which creates a new cache on every render. In development Reselect 5 flags the first with its `inputStabilityCheck` and `identityFunctionCheck` warnings.

code

ts · 15 lines
ts
import { configureStore } from '@reduxjs/toolkit'
import { rootReducer } from './rootReducer'
import { selectVisibleTodos } from './todosSelectors'
import { searchChanged } from './searchSlice'

test('unrelated dispatch keeps the visible todos reference', () => {
  const store = configureStore({ reducer: rootReducer })
  const before = selectVisibleTodos(store.getState())
  selectVisibleTodos.resetRecomputations()

  store.dispatch(searchChanged('milk'))

  expect(selectVisibleTodos(store.getState())).toBe(before)
  expect(selectVisibleTodos.recomputations()).toBe(0)
})

go deeper

for a junior

Recognise that filter and map always build a new array, and that a new array looks like changed data to a reference comparison.

for a middle

Rewrite the selector with createSelector so inputs extract todos and the filter and the result function does the filtering.

for a senior

Diagnose the ways memoisation silently fails, use recomputations() in a regression test, and explain what memoisation cannot prevent when the underlying data really changes.

for a principal

Decide which derived lists deserve memoised selectors and tests, and set team conventions that make unstable selectors visible in review.

## Reproducing the problem A typical todo list reads its visible items with a plain selector: ```ts const selectVisibleTodos = (state: RootState) => state.todos.filter((t) => state.filter === 'all' ? true : state.filter === 'done' ? t.completed : !t.completed, ) function TodoList() { const todos = useSelector(selectVisibleTodos) return todos.map((t) => <TodoItem key={t.id} todo={t} />) } ``` Profiling shows `TodoList` and every `TodoItem` re-rendering when the user types in an unrelated search box, when a notification arrives, or on any other dispatch. ## Why it happens 1. React-Redux re-runs the selector after each dispatch and compares the new result with the previous one by reference (`===`) by default. 2. `Array.prototype.filter` **always** returns a new array, even when it contains the same elements. 3. The comparison therefore reports a change after every dispatch, and `TodoList` re-renders. 4. Re-rendering a parent re-renders its children, so every `TodoItem` renders too. The data did not change; only the **reference** of the derived array did. The bug is in the selector, not in the component. ## The fix: memoise the derivation ```ts const selectTodos = (state: RootState) => state.todos const selectFilter = (state: RootState) => state.filter export const selectVisibleTodos = createSelector( [selectTodos, selectFilter], (todos, filter) => todos.filter((t) => (filter === 'all' ? true : filter === 'done' ? t.completed : !t.completed)), ) ``` Now the input selectors return existing references. A dispatch that does not touch `state.todos` or `state.filter` leaves both inputs unchanged, the result function is skipped, and the same array comes back. `useSelector` sees no change and `TodoList` does not re-render. ## Three ways to keep the bug while thinking you fixed it | Mistake | Why memoisation fails | |---|---| | filtering in an input selector: `createSelector([s => s.todos.filter(f)], t => t)` | the input returns a new array each call, so the cache never hits | | calling `createSelector(...)` in the component body | each render creates a new selector with an empty cache | | passing an inline object argument: `selectVisible(state, { filter })` | a new object each render is a new cache key | Reselect 5 helps with the first in development. On a selector's first call it runs two checks, each `'once'` by default: - **`inputStabilityCheck`** runs the input selectors twice with the same arguments and warns *"An input selector returned a different result when passed same arguments."* - **`identityFunctionCheck`** warns *"The result function returned its own inputs without modification"* when the result function just passes its input through. Both can be set to `'always'` or `'never'` per selector through the `devModeChecks` option, or globally with `setGlobalDevModeChecks`. ## What memoisation does not fix - **Toggling one todo** legitimately changes `state.todos`, so the selector recomputes and returns a new array; the list re-renders. With Immer-based reducers the unchanged todo objects keep their references, so items wrapped in `React.memo` receiving `todo` can skip rendering. How far to push subscription granularity is a React-Redux design question beyond the selector itself. - **Recomputes with equal content.** If a derivation often recomputes but produces the same elements — mapping to ids, for example — the memoiser's `resultEqualityCheck` option can compare the new result with the cached one and return the old reference when they are equal. ## Why not store the filtered list instead A tempting alternative is to keep `visibleTodos` in state and update it in the reducers. That trades a rendering problem for a consistency problem: - every reducer that changes `todos` **or** `filter` must also recompute `visibleTodos`; - a missed case leaves the list showing stale items with no error anywhere; - the state now holds the same todo twice, so an edit must be applied in two places. A memoised selector gives the same stable reference without duplicating data: the stored state stays minimal and the derived list is computed, cached and invalidated by the inputs themselves. ## How to verify - Call `selectVisibleTodos.recomputations()` before and after an unrelated dispatch in a test; it must not increase. - In the running app, watch the development console for the two Reselect warnings and React-Redux's own selector warnings. - Use a profiler to confirm that `TodoList` no longer renders on unrelated dispatches. The interview answer names the cause (a new array reference per call), the fix (`createSelector` with extracting inputs), and at least one way the fix is commonly defeated.

  • After adding createSelector, toggling one todo still re-renders the whole Redux todo list. Is the selector broken?
    No. Toggling changes `state.todos`, so the selector correctly recomputes and returns a new array, and the list re-renders. The unchanged todo objects keep their references, so child items wrapped in `React.memo` that receive the todo object can still skip rendering.
  • What does Reselect's inputStabilityCheck actually do in development?
    On a selector's first call by default, it runs the input selectors a second time with the same arguments and compares the results. If any differ by reference, it logs a warning that an input selector returned a different result for the same arguments, which means the result function would rerun more often than intended.

saying these in an interview costs you the question

  • useSelector deep-compares arrays, so filtering in a selector is harmless
  • Wrapping the filter in createSelector inside the component fixes it
  • Moving the filter into an input selector memoises it
  • A memoised selector prevents re-renders when a todo is toggled
  • Reselect's development checks also run in production builds