Why does a Redux todo list re-render every TodoItem on each dispatch when selectVisibleTodos filters state.todos, and how does Reselect fix it?
answer
- filter always builds a new array
- reference comparison sees a change
- memoise on todos and the filter
- development warnings name the mistake
basics
~20 sA 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 linesimport { 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
Recognise that filter and map always build a new array, and that a new array looks like changed data to a reference comparison.
Rewrite the selector with createSelector so inputs extract todos and the filter and the result function does the filtering.
Diagnose the ways memoisation silently fails, use recomputations() in a regression test, and explain what memoisation cannot prevent when the underlying data really changes.
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