Learn

Architecture & pattern guides

Read the idea, build it on a Grid, prove it in a challenge. Short, practical guides — illustrated with RodGrid's own nodes — on system design, design patterns, flow modeling, and how to get the most out of the app.

All articles

Unidirectional data flow: actions, store & view

Why UI state should change in one place: views emit actions describing what happened, a store computes the new state, and views redraw from it; reducers, selectors, replayable bugs, where side effects go, and what stays out of the store.

The badge says 3, the list shows 2

That was the whole bug report, filed by a responder on an incident console. The console shows the same incidents in three views: a list of unacknowledged incidents, a red badge in the navigation bar counting the ones nobody has acknowledged, and a detail pane for the selected incident. Each view could change that state where it stood, so nobody could say which one was wrong.

The views shared model objects, and two-way binding kept them in step: a view writes to a model, and every view bound to that model updates. One click did this:

  1. The responder clicks Acknowledge in the detail pane, which sets incident.status to acknowledged on the shared object.
  2. The list, bound to that object, re-runs its query and redraws all its rows.
  3. The badge keeps its own counter, which it lowers when the list reports a removed row.
  4. A redraw reports no removal, so the badge stays at 3 while the list shows 2.

Each view does what its author meant. The defect is in how changes travel: any view can write, a write in one view sets off handlers in another, and the count exists twice. Finding who last changed the badge means reading every handler in every view, in the order they happen to run, and a colleague's acknowledgement arriving from the server adds a fourth writer. Two-way binding is the style MVVM is known for, and MVVM: Model, View, ViewModel covers where it still fits.

Changes travel one way

Unidirectional data flow takes away the choice of where to write: no view changes shared state. The detail pane emits an action, a plain object describing what happened, { type: "incident/acknowledged", id: 812, by: "maya" }, which says what the user did, not which fields to change. A dispatcher passes every action, from every view, to the store, the single source of truth for incident state, which decides what the action means and computes the new state. The store then announces the change, and the list, the badge and the detail pane each read the new state and redraw. That announcement is the Observer pattern, with the store as the subject.

The arrows form a cycle that turns only one way. A view can start a change only by emitting an action, and it learns the result only by reading the store.

One click, one way around

The detail pane never touches the list or the badge: its action goes through the dispatcher to the store, and all three views redraw from what the store holds.

Who decides what an action means

Facebook's original Flux has the parts in that diagram, with one dispatcher and often several stores, say one for incidents and one for the on-call roster. The dispatcher sends every action to every registered store, and each store ignores the actions it does not care about.

Redux keeps the idea and changes the plumbing. One store holds the whole state as one object, and there is no separate dispatcher: store.dispatch(action) hands the action to a reducer, a function (state, action) => newState. For the acknowledgement, it returns a new state in which incident 812 is a new object whose status is acknowledged, and every other incident is the same object as before. A large app splits the reducer into smaller ones, one per slice of the state, and combines them, but every action still meets one root function. The reducer must be pure: the same state and action always give the same new state, with no network call, no timer and no random number inside.

A badge that cannot drift

The badge's bug came from keeping the count twice. With one store, the count is not kept at all. A selector computes what a view needs from the state when it needs it: countUnacknowledged(state) counts the incidents nobody has acknowledged, and the list uses unacknowledgedIncidents(state) over the same data. Two views can still disagree if a selector has a bug, but the search now ends in one of two small functions, not in a chain of handlers. Replay the click: the reducer changes one field on incident 812, both selectors run on the next redraw, and the list and the badge show 2 together, because neither kept a count of its own to forget.

Replaying a bug report

Every change enters through dispatch, so one line of code can log every action. The state at any moment is then a function of the initial state and the actions so far: feed the same actions through the reducer in the same order and you get the same state. A bug report can carry a snapshot of the state and the 50 actions that followed it, and a developer replays them to reach the store state the responder saw. Redux's developer tools go further and step backwards and forwards through the actions, which is called time-travel debugging.

Time travel also depends on the reducer returning a new state object instead of changing the old one. The old state stays intact, so stepping back means showing a state that still exists. Change detection gets cheap too: the incidents nobody touched keep the same object, so a view can tell whether its slice changed with one reference comparison, previous === next, instead of comparing every field. A reducer that edits incident 812 in place and returns the same object breaks both at once: a view that relies on the comparison sees no change and does not redraw, and every saved earlier state that holds that object now shows incident 812 as acknowledged too.

Event Sourcing rebuilds state from events too, but keeps its log on a server as the durable system of record; a client store and its actions live in one tab's memory and are gone when the tab closes, unless the app saves them.

Where the network call goes

Acknowledging an incident also means telling the server, and the reducer cannot do that, because it must stay pure. Side effects run outside it, often in Redux middleware such as the one that runs thunks. The click dispatches incident/ackRequested; the effect sends the request; when the answer arrives it dispatches incident/ackSucceeded, which now marks incident 812 acknowledged, or incident/ackFailed, and the reducer handles those like any other action. Purity pays off in replay: running the logged actions through the reducer again recomputes the state without sending the request a second time.

What stays out of the store

The indirection has a price. Acknowledging one incident now touches an action type, the code that builds the action, a reducer case and a selector, where the two-way binding needed one assignment. It pays when several views share state that changes often, as the console's do, and costs more than it saves on a settings page with three switches.

Not every piece of state belongs in the store, either. The text a responder is typing into the note field, the row under the pointer, whether the escalation menu is open: no other view reads them, and sending each keystroke through dispatch adds work without adding a reader. They stay as local state in the view that owns them.

And the store belongs to one client. It holds this tab's copy of what the server said, not the incidents themselves. When a colleague acknowledges incident 812 on another laptop, this console learns it only when the server tells it, through a pushed message or a fresh fetch, and that answer is dispatched as an action. Keeping that copy fresh is a caching problem the store does not solve, and two responders changing the same incident at once is Conflict resolution for concurrent writes.

Drawing the cycle

The Unidirectional Data Flow (Flux) Challenge asks for this cycle in at most eight nodes. The palette has no Dispatcher node, so use the Coordinator, and draw each action as an Event; the console fits in six nodes, as in the diagram above. Before you finish, check one thing: every arrow that leaves a view goes to an Event, and only the Store points back at the views.

Practice this