---
title: 'Centralized Store Pattern'
source: 'https://academia.sh/en/courses/frontend-architecture/centralized-store-pattern'
course: 'Application Architecture: Routing, State and Data'
language: en
updated: '2026-08-17T18:11:05+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```js
// 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.
