Skip to content
academia.sh

Lesson 07 / 22

Centralized Store Pattern

The pattern that collects state in one place — one-way data flow, the pure reducer, the action contract, filtering unnecessary notifications with selectors, where middleware fits, and the pattern's indirection cost.

Contents

State kinds are separated, but how shared state is held is not clear. A value kept at the common ancestor gets threaded through dozens of components that do not use it as the tree deepens. The heavier problem is the scatter of change: in the North Slope Measurement Station application, selecting a row, deleting a measurement, and signing out change the same state from three separate places. When a wrong value appears on screen, who wrote it cannot be traced.

The centralized store pattern answers both problems with a single constraint: state is held in one place and changes only through named actions.

One-Way Flow

The pattern’s backbone is a cycle that closes across four stops. The view dispatches an action; the action goes to the reducer; the reducer produces the new state; the new state flows back to the view.

There is no way to change state outside this cycle. The constraint is the source of all the pattern’s gains: because every change passes through a single point, changes can be ordered, recorded, and replayed.

The three parts are defined separately. An action is a data object reporting an event that has occurred; it has a type and as many fields as needed. A reducer is a pure function that takes the current state and an action and returns the new state. A store is the container that holds the current state and provides dispatching actions and observing change.

The Reducer

The reducer is a pure function: the same input always produces the same output, and nothing outside changes. It makes no network request, generates no random number, reads no current time.

It does not mutate state, it produces a new one. This is a direct application of the immutability rule from the Programming Fundamentals course, and it has a practical consequence: if a slice’s reference has not changed, that slice has not changed. This cheap test decides when the view is redrawn.

// centralized-store.mjs — reducer, action sequence, middleware, selector, and replay
const INITIAL_STATE = { stations: [], selected: null, filter: "all" };

function reducer(state, action) {
  switch (action.type) {
    case "stations/loaded":
      return { ...state, stations: action.data };
    case "station/selected":
      return state.stations.some((s) => s.id === action.id)
        ? { ...state, selected: action.id }
        : state;                                   // invalid selection does not change state
    case "station/added":
      return { ...state, stations: [...state.stations, action.station] };
    case "station/deleted":
      return {
        ...state,
        stations: state.stations.filter((s) => s.id !== action.id),
        selected: state.selected === action.id ? null : state.selected,
      };
    case "filter/changed":
      return { ...state, filter: action.value };
    default:
      return state;                                // unknown action: same reference
  }
}

function createStore(reducer, initial, middlewares = []) {
  let state = initial;
  const listeners = new Set();
  const base = (action) => {
    state = reducer(state, action);
    for (const l of listeners) l(state);
  };
  const dispatch = middlewares.reduceRight(
    (next, mw) => mw({ getState: () => state })(next), base);
  return { getState: () => state, dispatch, subscribe: (f) => listeners.add(f) };
}

const log = [];
const logMiddleware = ({ getState }) => (next) => (action) => {
  const previous = getState();
  next(action);
  log.push({ action: action.type, changed: getState() !== previous, state: getState() });
};

const ACTIONS = [
  { type: "stations/loaded", data: [{ id: "north-slope" }, { id: "east-ridge" }] },
  { type: "station/selected", id: "north-slope" },
  { type: "filter/changed", value: "temperature" },
  { type: "station/selected", id: "west-valley" },     // not in list
  { type: "station/added", station: { id: "west-valley" } },
  { type: "station/selected", id: "west-valley" },
  { type: "station/deleted", id: "west-valley" },
  { type: "theme/changed", value: "dark" },               // reducer does not define this
];

const store = createStore(reducer, INITIAL_STATE, [logMiddleware]);

// Selector: the slice the component cares about. If the reference did not change, no re-render is needed.
const selectorStats = { notified: 0, listChanged: 0 };
let previousList = store.getState().stations;
store.subscribe((s) => {
  selectorStats.notified += 1;
  if (s.stations !== previousList) { selectorStats.listChanged += 1; previousList = s.stations; }
});

for (const a of ACTIONS) store.dispatch(a);

console.log("-- action log --");
console.log("#".padEnd(3), "action".padEnd(24), "changed".padEnd(8), "stations", "selected");
log.forEach((g, i) => console.log(
  String(i + 1).padEnd(3), g.action.padEnd(24), String(g.changed).padEnd(8),
  String(g.state.stations.length).padEnd(8), String(g.state.selected)));

console.log("-- listener --");
console.log("dispatched actions:", ACTIONS.length,
  "| notifications:", selectorStats.notified,
  "| list slice changed:", selectorStats.listChanged);

// The reducer is pure, so the same sequence gives the same result.
console.log("-- replay --");
const replayed = ACTIONS.reduce(reducer, INITIAL_STATE);
console.log("final state matches:", JSON.stringify(replayed) === JSON.stringify(store.getState()));
console.log("after action 3:", JSON.stringify(ACTIONS.slice(0, 3).reduce(reducer, INITIAL_STATE)));
console.log("final state    :", JSON.stringify(store.getState()));
-- action log --
#   action                   changed  stations selected
1   stations/loaded          true     2        null
2   station/selected         true     2        north-slope
3   filter/changed           true     2        north-slope
4   station/selected         false    2        north-slope
5   station/added            true     3        north-slope
6   station/selected         true     3        west-valley
7   station/deleted          true     2        null
8   theme/changed            false    2        null
-- listener --
dispatched actions: 8 | notifications: 8 | list slice changed: 3
-- replay --
final state matches: true
after action 3: {"stations":[{"id":"north-slope"},{"id":"east-ridge"}],"selected":"north-slope","filter":"temperature"}
final state    : {"stations":[{"id":"north-slope"},{"id":"east-ridge"}],"selected":null,"filter":"temperature"}

The log’s fourth and eighth rows show the same behavior for two different reasons. Selecting a station not in the list is rejected; the reducer returns the current state with the same reference. An unrecognized action type is handled the same way. In both cases no view needs to redraw, and a single reference comparison is enough to know that.

The seventh row shows that a single action can affect more than one slice. When the selected station is deleted, the list shortens and the selection clears at once; these two changes are not split into separate actions, because no moment must exist between them where the screen is left inconsistent. The invariant is this: the selected id is either empty or found in the list.

Replay

The last section shows what purity buys. When the action sequence is reduced from the start, the exact state that formed in the store is obtained. A store is not even needed; state is a function of the initial value and the action sequence.

This has three uses. When a bug report from a user comes with the action log, the problem can be reproduced locally. Returning to any moment is done by re-reducing the actions up to that point — the output’s state after the third action was produced this way. And an undo function is built by removing the deleted action and re-reducing the sequence.

These capabilities depend on the reducer’s purity. The moment a single line reading the current time is added inside it, replay produces a different result and all three are lost.

Selector

The listener section gives, in numbers, the pattern’s most commonly overlooked cost: eight actions were dispatched, eight notifications were produced, but the station list changed only three times.

The store does not know which part of state changed; it notifies all listeners on every dispatch. Components must filter these notifications themselves. A selector is a function that extracts, from state, the slice a component cares about; if the slice’s reference has not changed, no redraw happens.

The selector has one trap. A selector that produces a new object or array on every call — one that filters, maps, or aggregates into an object — always fails the reference comparison, and the filter never works. Selectors of this kind are memoized so they return the same result as long as their inputs have not changed.

Middleware

If the reducer must be pure, where do a network request, writing to a log, and saving to storage go? Middleware is a wrapper that sits on the dispatch path and processes an action before or after it reaches the reducer.

The log middleware in the example passes the action through, then compares the state before and after and records it. Other responsibilities sit in the same place: turning an action into an asynchronous process, writing state to storage after certain actions, validating an incoming action.

The order of middleware is meaningful: the outer layer wraps the inner one. If the log middleware sits outermost, it also sees the actions an asynchronous layer produces; if it sits innermost, it sees only the ones that reach the reducer.

The Pattern’s Cost

The centralized store is not free. To change a single field, an action type is defined, a branch is added to the reducer, a dispatch is written — three files are touched. This indirection is paid for a single thing: the traceability of change. Where traceability has no value, the cost is also unnecessary.

Two limits follow from this. Local state is not put in the store; defining an action for a dropdown’s openness is indirection that brings no gain. Server state is not put in it either — it can be copied, but the questions of freshness, refetching, and request repetition are questions the reducer cannot solve; that layer is the subject of this topic’s fourth lesson.

What belongs in the store is shared state the application itself produces and that is changed from more than one place.

Summary

  • The centralized store pattern collects state in one place and accepts change only through named actions; the flow closes in one direction across view, action, reducer, and state.
  • The reducer is a pure function that produces new state without mutating it; an unchanged slice keeps its reference, which reduces the redraw decision to a single comparison.
  • Purity makes it possible to reproduce state from the action sequence: a bug can be replayed, a past moment can be returned to, undo can be built.
  • The store notifies all listeners on every dispatch; selectors filter out unnecessary redraws, and a selector that produces a new object does not filter anything unless it is memoized.
  • Side effects live in middleware; the order of middleware decides which actions get seen.
  • The pattern’s cost is indirection; local state and server state are not put in the store.

Next Step

The centralized store established a single source of truth, but it came with a cost: every change renews a single state object and is announced to every listener. Unnecessary redrawing is prevented by correctly written selectors — meaning correctness depends on every consumer building its own filter flawlessly. There is another approach that reverses this responsibility: defining state not as one large object but as small units linked to each other. Then who reads what falls out of the dependency itself, and only the affected consumers are notified. The next lesson covers this fine-grained model — atoms and derived atoms.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close