What contract must a Redux reducer honour for undefined state, unknown actions and side effects, and why does Redux depend on it?
answer
- (state, action) => newState
- undefined in, initial state out
- unknown action, same reference back
- no API calls, no randomness, no dispatch
basics
~20 sA Redux reducer takes (state, action) and returns the next state: its initial state when given undefined, the same state for actions it ignores, never undefined, and without side effects, so replaying actions always reproduces the same state.
solid answer
~50 sA reducer is a pure function `(state, action) => newState`. When `state` is `undefined` it returns its initial state, usually via a default parameter. For an action it does not handle it returns the **same** state object, not a copy, so nothing downstream sees a change. It must never return `undefined`; `null` is fine if it means "no value". It must not have side effects: no API calls, timers, `Math.random()` or `Date.now()`, no dispatching and no mutation of its arguments. Redux enforces part of this: the store throws if a reducer calls `dispatch` or `getState` while it runs, and `combineReducers` probes each slice with `undefined` and an unknown action type and throws if the result is `undefined`. The payoff is determinism: the same actions always produce the same state, which is what makes testing, replay and time-travel debugging work.
code
ts · 20 linesimport type { UnknownAction } from 'redux'
type Visibility = 'all' | 'active' | 'completed'
interface FiltersState {
visibility: Visibility
}
const initialState: FiltersState = { visibility: 'all' }
export function filtersReducer(
state: FiltersState = initialState, // clause 1: undefined -> initial
action: UnknownAction
): FiltersState {
switch (action.type) {
case 'filters/visibilityChanged':
return { ...state, visibility: action.payload as Visibility } // new object
default:
return state // clause 2: same reference
}
}go deeper
Recite the shape: (state, action) returns new state; default state for undefined; return state unchanged in the default branch.
Explain each clause's reason: same reference for no change, undefined reserved for uninitialized, and purity for replay; mention what the store and combineReducers enforce.
Spot contract breaches in review, such as copies on unknown actions, Date.now() in a reducer or a slice returning undefined, and explain their production symptoms.
Explain why the pure-reducer constraint is a deliberate design choice that trades flexibility for replayability, and when a team should move logic elsewhere.
## The signature A Redux reducer is a function with one job: ```ts (state: S | undefined, action: UnknownAction) => S ``` Given the current state and one action, it **returns the next state**. The name comes from `Array.prototype.reduce`: if you reduced an array of actions with the reducer, you would get the final state. That analogy is the whole contract in miniature, because `reduce` only works if the callback is deterministic. ## The four clauses 1. **Undefined state → initial state.** The store calls the root reducer at creation with a private `@@redux/INIT` action and, unless preloaded, `undefined` state. The reducer must answer with its initial value. A default parameter, `state = initialState`, is the idiom. 2. **Unknown action → the same state.** Most actions are not for most reducers. For those, return the `state` argument **itself**. Returning a copy "to be safe" signals a change that did not happen, and reference checks downstream then do unnecessary work. 3. **Never `undefined`.** `undefined` is reserved to mean "not initialized yet". If a slice really has no value, use `null`. 4. **No side effects.** Compute the result from `state` and `action` only, and do not mutate either. ## What counts as a side effect | Forbidden in a reducer | Why | Where it goes instead | |---|---|---| | API calls, timers, promises | Async results cannot be returned synchronously | Middleware (thunks, listeners) | | `Math.random()`, `Date.now()` | Replays would produce different state | Action creator, before dispatch | | `dispatch` | Actions must not trigger actions mid-update | Middleware or the calling code | | Mutating `state` or `action` | Breaks reference-based change detection and history | Return new objects | | Writing to outer variables or storage | Hidden coupling; runs again on replay | A store subscriber or middleware | Calling other **pure** helper functions from a reducer is fine; the rule is about effects, not about where code lives. ## What Redux enforces at runtime The core does not trust reducers blindly: - **`dispatch` inside a reducer** throws `Reducers may not dispatch actions.` - **`getState` inside a reducer** throws: the reducer already has the state as its argument. - **`subscribe`/unsubscribe inside a reducer** throws as well. - **`combineReducers` probes each slice reducer** with `undefined` state, once with the INIT type and once with a randomly generated `@@redux/PROBE_UNKNOWN_ACTION…` type. If a slice returns `undefined` for either, the combined reducer throws on its first call, which means store creation fails with a message telling you to return the initial state. The random type exists precisely to catch reducers that handle the private INIT type instead of writing a proper default. - **At runtime**, if a slice returns `undefined` for a real action, `combineReducers` throws, naming the slice key and the action type. It does **not** detect impurity or mutation in general; that is on you, or on development tooling. ## Why Redux depends on it - **Determinism.** Same state plus same action gives the same result, so a bug report's action log reproduces the bug. - **Time travel and replay.** Debugging tools re-run reducers over recorded actions. Side effects would fire again, and random values would differ. - **Cheap change detection.** Returning the same reference for no change, and new references only on the path that changed, lets `combineReducers` and UI bindings skip work with a `===` check. - **Testing.** A reducer test is input, output and an assertion, with no mocks. ## Breaches you will see in code review 1. **`default: return { ...state }`**: a copy for every unrelated action. Nothing breaks visibly, but every subscriber comparing this slice sees a change on every dispatch. 2. **A missing `default` branch** in a `switch`, so the function falls through and returns `undefined`. Under `combineReducers` the probe catches this and store creation throws, which is at least loud; a lone root reducer would silently set the state to `undefined`. 3. **`id: Date.now()` inside a case**: works in the demo, then produces different state when actions are replayed or when two items are added in the same millisecond. 4. **Fetching inside a case**, for example `case 'user/loaded': api.track(...)`. The effect runs again on replay and runs even if the update is later discarded. 5. **Sorting in place**, `state.items.sort(...)`, often hidden in a helper: a mutation that changes the previous state as well. Each of these passes a quick glance because the reducer still returns *something* plausible. The contract is what makes them visible as bugs. ## A note on the private action types The `@@redux/INIT`, `@@redux/REPLACE` and probe types are **private** and include a random suffix. Never switch on them; rely on the undefined-state clause instead.
- Why does combineReducers probe with a random action type as well as INIT?A reducer could satisfy the INIT probe by special-casing the private INIT type instead of handling undefined state properly. The random type is unknown to any reducer, so only a reducer that returns its initial state whenever state is undefined, whatever the action, passes. That is the behaviour Redux actually relies on.
- Is console.log inside a reducer a violation of the no-side-effects rule?Strictly, yes, it is an effect, but the Redux style guide calls it a grey area because it does not change how the app behaves. The line that matters is effects that alter behaviour or run twice on replay: network calls, timers, randomness, dispatching and writes to shared state.
saying these in an interview costs you the question
- Return a shallow copy for unknown actions to be safe
- Returning undefined tells combineReducers to skip the slice
- Handle @@redux/INIT to set up initial state
- A reducer may dispatch a follow-up action when it finishes
- Reading store.getState() inside a reducer gets the freshest state