skip to content

When does a Redux store call the listeners registered with store.subscribe, and do they fire when the reducer returns the same state?

level: middleimportance: should knowfreq 40%

answer

  1. the store never compares states
  2. every dispatch, every listener
  3. listener list snapshotted per dispatch
  4. subscribe returns unsubscribe

basics

~20 s

A Redux store calls every subscribed listener after every dispatch, even when the reducer returned the identical state object; the store never compares old and new state, so skipping unchanged data is the subscriber's job.

solid answer

~40 s

The core `dispatch` runs the reducer, stores the result and then calls **all** listeners, with no arguments and no check on whether anything changed. That is why UI bindings such as react-redux keep the last value they read and compare it before re-rendering. Two more details matter. The list of listeners is snapshotted at the start of each notification pass, so subscribing or unsubscribing from inside a listener takes effect from the next dispatch. And if a listener dispatches again, other listeners may skip seeing the intermediate state; Redux only guarantees that every listener registered before the dispatch started is called with the latest state by the time it returns. `subscribe` returns an `unsubscribe` function, and calling `subscribe` while a reducer is running throws.

code

ts · 21 lines
ts
import { legacy_createStore as createStore, type UnknownAction } from 'redux'

function themeReducer(state = { mode: 'light' }, action: UnknownAction) {
  return action.type === 'theme/toggled'
    ? { mode: state.mode === 'light' ? 'dark' : 'light' }
    : state // same reference for anything else
}

const store = createStore(themeReducer)
let last = store.getState()

const unsubscribe = store.subscribe(() => {
  const next = store.getState()
  console.log('listener ran; changed?', next !== last)
  last = next
})

store.dispatch({ type: 'analytics/pageViewed' }) // listener ran; changed? false
store.dispatch({ type: 'theme/toggled' })       // listener ran; changed? true
unsubscribe()
store.dispatch({ type: 'theme/toggled' })       // no output

go deeper

for a junior

Remember that subscribe registers a callback that runs after dispatch, takes no arguments, and returns a function that unsubscribes it.

for a middle

Explain that the store never compares states, so every listener runs on every dispatch, and describe the snapshot rule for subscribing or unsubscribing during notification.

for a senior

Connect notification cost to real performance: many dispatches times many subscribers, expensive selectors run on no-op actions, and leaked subscriptions that are never removed.

for a principal

Discuss why Redux puts change detection in the subscriber rather than the store, and what that means when you design a custom binding or a persistence layer on top of it.

## What `subscribe` is for `store.subscribe(listener)` registers a **change listener**: a function the store calls after actions are dispatched. It is the low-level hook that every UI binding (react-redux, other framework adapters, persistence helpers) builds on. Understanding exactly when it fires explains a lot of behaviour that looks like magic from the component side. ## When listeners fire - **After every dispatch that reaches the store.** The core `dispatch` runs the root reducer, saves the returned value as the current state, and then calls every listener. - **Regardless of whether state changed.** There is no `===` check between the old and new root state. If a reducer returns the same object for an action it ignores, listeners still run. - **With no arguments.** The listener gets neither the action nor the state. It calls `store.getState()` if it needs the current value. - **Never during the reducer.** Notification starts only after the reducer has returned, so listeners never see a half-computed state. This design keeps the store tiny and puts the "did anything I care about change?" decision where the knowledge lives: in the subscriber. react-redux, for example, reruns each component's selector and compares the result with the previous one before it re-renders anything. ## The snapshot rule The store keeps two references to its listener collection, the current one and the next one. When you subscribe or unsubscribe, the store copies the collection first if a notification pass might be iterating it. At the start of each notification pass it takes the latest collection as the one to iterate. The consequences, from the documented contract: 1. **Subscribing inside a listener** does not make the new listener run during the current pass. It runs from the next dispatch. 2. **Unsubscribing inside a listener** does not stop an already-snapshotted listener from being called in the current pass. The removal applies from the next dispatch. 3. **Nested dispatches** (a listener that dispatches) start a new pass with a fresh snapshot. A listener may therefore not see every intermediate state, but every listener registered before the outer dispatch started is guaranteed to have been called with the latest state by the time that dispatch returns. ## What throws | Call | While the reducer runs | While listeners run | |---|---|---| | `store.getState()` | Throws | Allowed | | `store.subscribe(fn)` | Throws | Allowed (effective next dispatch) | | `unsubscribe()` | Throws | Allowed (effective next dispatch) | | `store.dispatch(action)` | Throws: "Reducers may not dispatch actions." | Allowed (nested dispatch) | ## The return value `subscribe` returns an **unsubscribe function**. Calling it twice is harmless; the second call is a no-op. Forgetting to call it when a component or module goes away is a classic leak: the store keeps a reference to the closure and to everything the closure captured. ## How a UI binding builds on it A typical Redux UI binding follows this recipe on top of the primitive: 1. **Subscribe to the store once**, often at the provider, and fan each notification out to the consumers that registered with it. 2. **On each notification**, read `store.getState()`. 3. **Select** the slice each consumer needs, using the consumer's selector function. 4. **Compare** the new selection with the last one, by reference by default. 5. **Schedule a render** only for consumers whose selection changed. 6. **Unsubscribe** when the consumer unmounts. Because steps 2 to 4 run on **every** dispatch, selector cost is paid by every subscribed consumer on every action, including actions that changed nothing it reads. A selector that builds a new array on every call defeats step 4 and turns every dispatch into a re-render, which is why memoized selectors exist. ## A nested dispatch, traced Suppose listeners L1 and L2 are subscribed, and L1 dispatches action `b` the first time it runs (a flag stops it doing so again): 1. `dispatch(a)` runs the reducer and starts pass A over [L1, L2]. 2. L1 runs and calls `dispatch(b)`; the reducer runs again and pass B calls L1 (which does nothing now) and then L2, both seeing the state after `b`. 3. Pass A resumes and calls L2, which reads the same state after `b`. L2 never observed the state between `a` and `b`, and it ran twice with the same state. Both are allowed by the contract. ## Practical consequences - **Cost is per dispatch, not per change.** Many listeners and many dispatches multiply. Dispatching ten actions in a row means ten notification passes, which is one reason the Redux style guide advises modelling one meaningful event as one action. - **Your listener must be cheap and idempotent.** It runs on every dispatch, including no-op ones, so it should read, compare and bail out quickly. - **Do not rely on seeing every state.** A listener that must react to every individual change should be middleware instead, because middleware sees each action in order. - **Interop exists.** The store also exposes a minimal observable (via `Symbol.observable`) built on the same `subscribe`, for reactive libraries that expect that protocol.

  • If the store always notifies, how does react-redux avoid re-rendering every connected component on every dispatch?
    It keeps each component's last selected value and reruns the selector on each notification. The component re-renders only when the new selected value is not equal to the previous one, by reference by default. So the notification is cheap, and the real cost is the selectors themselves.
  • Why can't a listener tell which action caused the notification, and what should you use if you need that?
    The store calls listeners with no arguments, by design: they are change notifications, not event handlers. If logic must react to specific actions, write it as middleware, which sees every action before the reducer, or use an action-listening middleware, rather than trying to infer the action from state differences.

saying these in an interview costs you the question

  • The store skips listeners when the reducer returns the same state object
  • Listeners receive the action so they can filter by type
  • Unsubscribing inside a listener removes it from the current notification pass
  • Listeners are guaranteed to observe every intermediate state during nested dispatches
  • store.subscribe returns an ID you pass to store.unsubscribe