Flux and Unidirectional Data Flow: Why Didn't Two-Way Binding Scale?
Two-way binding and observable models that update each other produce cascading updates whose result depends on listener order, plus intermediate states that were never valid (glitches); Flux solved this with a one-way flow (action → dispatcher → store → view). We show the glitch with real output on the same order form, cover Redux's three principles and the Elm Architecture, write a 40-line TypeScript store and weigh the cost of immutability and boilerplate.
Version note: The examples in this post were tested with Angular 22 and TypeScript.
Guess First
Back to the order form. The user picks a book, enters a quantity, and the total shows up at the bottom of the screen. There is also a rule: 3 or more copies get a 10 percent discount. With the Observer Synchronization idea from the earlier posts, where every model notifies its listeners when it changes, building this form feels natural. Below are four small observable models, each updating the next one. The view only listens to total.
// Every model notifies its listeners as soon as it changes
class Observable<T> {
private listeners: ((value: T) => void)[] = [];
constructor(private current: T) {}
get value(): T {
return this.current;
}
set(next: T): void {
if (next === this.current) return;
this.current = next;
this.listeners.forEach(listener => listener(next));
}
subscribe(listener: (value: T) => void): void {
this.listeners.push(listener);
}
}
const price = 14;
const quantity = new Observable(2);
const subtotal = new Observable(quantity.value * price);
const discount = new Observable(0);
const total = new Observable(subtotal.value);
// Wiring: each model updates the next one
quantity.subscribe(q => subtotal.set(q * price));
subtotal.subscribe(s => total.set(s - discount.value));
subtotal.subscribe(s => discount.set(quantity.value >= 3 ? s / 10 : 0));
discount.subscribe(d => total.set(subtotal.value - d));
// The view re-renders whenever total changes
total.subscribe(t => console.log(`render: total=${t.toFixed(2)} discount=${discount.value.toFixed(2)}`));
quantity.set(3);
The selected book is The Hobbit at 14 euros; when the quantity goes from 2 to 3, the subtotal becomes 42 and the discount rule kicks in. How many lines do you think get printed, and what values does each line show? Second question: if you swap the two lines that start with subtotal.subscribe, does the output change? Think for a minute, then read on.
Two-Way Binding and Cascading Updates
The actual output:
$ node out/cascade.js
render: total=42.00 discount=0.00
render: total=37.80 discount=4.20
The view renders twice, and the first render shows a state that was never valid: a total of 42 with no discount. The rule says "3 copies or more means a discount", yet for a moment the screen says the opposite. The cause is that two listeners run one after the other when subtotal changes. The first listener computes total with the old discount and triggers the view; the discount is only updated in the second listener. The reactive programming literature calls this kind of intermediate, inconsistent state a "glitch".
The answer to the second question is more unsettling: when we swap the two subscribe lines, the output drops to a single line, render: total=37.80 discount=4.20. What you see on the screen depends on the order in which the code was wired up. With four models you can follow this in your head. With forty models, dozens of screens and bindings that update each other, you cannot.
Two-way binding makes the problem bigger because the arrow points both ways: the view updates the model, the model updates the view, and one model change can trigger another model through another binding. AngularJS tried to solve this with the digest loop: it ran the watchers again and again until changes stopped, and if they did not stop within ten rounds it gave up with the error 10 $digest() iterations reached. Aborting!. That error was not a bug; it was the safety valve of an architecture that could not guarantee its loop would terminate.
The Facebook team ran into the same problem with the unread message counter in their chat screen. The example in the Flux documentation is exactly this: when a thread is marked as read, both the thread model and a separate "unread count" model have to be updated. The documentation describes how, in a large app built with MVC (Model-View-Controller), such dependencies produced a tangled data flow and unpredictable results. In the 2014 talk at F8 (Facebook's annual developer conference) where Jing Chen introduced Flux, the starting point was this very counter that showed users false unread notifications.
Flux: Action, Dispatcher, Store, View
Flux proposes a single rule: data flows in one direction. The flow is action → dispatcher → store → view, and an interaction in the view starts the cycle again by creating a new action. The documentation sums it up plainly as "no two-way bindings".

- Action: a plain object with a
typeand some data. It carries "what happened"; it can come from the view or from the server. - Dispatcher: the hub that hands every action to all registered stores. In the documentation's words it is a registry of callbacks into the stores; it has no logic of its own.
- Store: holds the state of one area and the logic that changes that state. It exposes no setters; it changes only through an action coming from the dispatcher, then emits a change event.
- View: listens to the store, reads the state and draws it. When the user does something, it does not change the state; it sends an action.
The key point here is inversion of control. In MVC, a controller or another model could reach into a store with a method like thread.setAsRead(). In Flux the store has no such method; only the store changes its own state. The order between stores is also written out explicitly with the dispatcher's waitFor() method: the unread count store can wait for the thread store to finish. The "subscription order changes the result" problem from the earlier example turns into a dependency you can see in the code.
Facebook's Dispatcher implementation has one more rule: if a new dispatch starts while an action is being dispatched, it throws Cannot dispatch in the middle of a dispatch.. That is the line that bans cascading updates at the architecture level. If the result of one action needs another action, the second starts only after the first has fully finished; every update happens in a single round. This will feel familiar from the Design Patterns series: the dispatcher is a Mediator, and the action is a form of turning a request into an object, as in the Command pattern of the GoF (Gang of Four: Gamma, Helm, Johnson and Vlissides, authors of the 1994 Design Patterns book). The original Flux library is archived today, but the idea lives on everywhere.
The Three Principles of Redux
In 2015 Dan Abramov and Andrew Clark released Redux, a simplification of Flux. The Redux documentation sums up the approach in three principles:
| Principle | What it says | Difference from Flux |
|---|---|---|
| Single source of truth | The global state of the app lives in a single object tree in a single store. | Flux had several stores, one per area. |
| State is read-only | The only way to change the state is to send an action that describes what happened. | Same idea; Redux reduces it to one rule. |
| Changes are made with pure functions | Pure reducers describe how an action transforms the state: (state, action) => newState. | The dispatcher and waitFor() become unnecessary; reducers are combined. |
A single store and pure reducers remove the ordering problem between stores that Flux had. Because all state passes through one function, the question "which one updates first" comes down to how you write the reducer, and that is readable code inside a single function. Purity also gives you testability for free: to test a reducer you pass in a state and an action and look at the return value; no DOM (Document Object Model), no mocks, no framework.
The Elm Architecture: Model, Update, View
One of Redux's inspirations is Evan Czaplicki's Elm language and its application structure, The Elm Architecture. The Elm guide describes it in three parts: Model is the state of the app, View is the function that turns the state into HTML (HyperText Markup Language), and Update is the function that changes the state based on an incoming message. When the user clicks a button, the view produces a message, the runtime hands that message to update, and the new model goes to the view.
In Elm you cannot step outside this flow: values are immutable, there is no mutation, and side effects happen only through commands handed to the runtime (Cmd in Elm's terms). In TypeScript the language does not give you this discipline; you have to build it. The store below tries to do that: Elm's Msg corresponds to the action, update to the reducer, and view to the render function that listens to the store.
A 40-Line Store
export type Reducer<S, A> = (state: S, action: A) => S;
export type Listener = () => void;
export interface Store<S, A> {
getState(): S;
dispatch(action: A): void;
subscribe(listener: Listener): () => void;
}
export function createStore<S, A>(reducer: Reducer<S, A>, initialState: S): Store<S, A> {
let state = initialState;
let reducing = false;
const listeners = new Set<Listener>();
return {
getState: () => state,
dispatch(action) {
// The rule from the Flux Dispatcher: no new action while one is being processed
if (reducing) throw new Error(`Cannot dispatch while reducing: ${JSON.stringify(action)}`);
reducing = true;
let next: S;
try {
next = reducer(state, action);
} finally {
reducing = false;
}
// If the reference did not change, nothing changed
if (next === state) return;
state = next;
listeners.forEach(listener => listener());
},
subscribe(listener) {
listeners.add(listener);
return () => listeners.delete(listener);
},
};
}
There are three methods. getState returns the current state, subscribe adds a listener and returns a function that ends the subscription, and dispatch runs the reducer and notifies the listeners. Two small decisions tie this store to the lessons of Flux. The first is rejecting a dispatch that arrives while the reducer is running; it is the same rule as in Facebook's Dispatcher. The second is notifying nobody if the reducer returns the same reference. That second one is where immutability pays off, and we will come back to it shortly.
If you try to start a new action from inside the reducer, here is the result:
import { createStore } from './store.js';
import { initialOrderState, orderReducer, type OrderAction, type OrderState } from './order.js';
// Bad example: starting a new action from inside the reducer
const store = createStore((state: OrderState, action: OrderAction): OrderState => {
const next = orderReducer(state, action);
if (action.type === 'bookSelected') store.dispatch({ type: 'quantityChanged', quantity: 2 });
return next;
}, initialOrderState);
store.dispatch({ type: 'bookSelected', book: { id: 1, title: 'The Hobbit', price: 14 } });
$ node out/nested.js
Error: Cannot dispatch while reducing: {"type":"quantityChanged","quantity":2}
Redux has the same rule: dispatching from inside a reducer throws. If the quantity has to be 2 when a book is selected, that belongs in the reducer itself, not in a second action.
Moving the Order Form to the Store
Now let's move the form from the opening into this structure. The state holds only source data: the selected book, the quantity, the submission status and the order number. Subtotal, discount and total are not in the state; they are computed from the state on every read.
export interface Book {
readonly id: number;
readonly title: string;
readonly price: number;
}
export interface OrderState {
readonly book: Book | null;
readonly quantity: number;
readonly status: 'editing' | 'submitting' | 'submitted';
readonly orderId: string | null;
}
// Actions describe "what happened"; they are not setters
export type OrderAction =
| { type: 'bookSelected'; book: Book }
| { type: 'quantityChanged'; quantity: number }
| { type: 'submitRequested' }
| { type: 'submitSucceeded'; orderId: string };
export const initialOrderState: OrderState = { book: null, quantity: 1, status: 'editing', orderId: null };
export function orderReducer(state: OrderState, action: OrderAction): OrderState {
switch (action.type) {
case 'bookSelected':
return { ...state, book: action.book, quantity: 1 };
case 'quantityChanged': {
const quantity = Math.min(Math.max(action.quantity, 1), 10);
// No change means the same reference; nothing re-renders
return quantity === state.quantity ? state : { ...state, quantity };
}
case 'submitRequested':
return canSubmit(state) ? { ...state, status: 'submitting' } : state;
case 'submitSucceeded':
return { ...state, status: 'submitted', orderId: action.orderId };
default:
return assertNever(action);
}
}
// Derived values are not stored in the state; they are computed from it
export const selectSubtotal = (s: OrderState): number => (s.book ? s.book.price * s.quantity : 0);
export const selectDiscount = (s: OrderState): number => (s.quantity >= 3 ? selectSubtotal(s) / 10 : 0);
export const selectTotal = (s: OrderState): number => selectSubtotal(s) - selectDiscount(s);
export const canSubmit = (s: OrderState): boolean => s.book !== null && s.status === 'editing';
function assertNever(action: never): never {
throw new Error(`Unhandled action: ${JSON.stringify(action)}`);
}
Notice that the action names are in the past tense: quantityChanged, bookSelected. The Redux Style Guide recommends this as "model actions as events, not setters". An action called setQuantity moves the reducer's work to the caller; quantityChanged only says what happened and leaves the rules (clamping between 1 and 10, resetting the quantity when the book changes) to the reducer.
Using the form and the submission flow:
import { createStore } from './store.js';
import { initialOrderState, orderReducer, selectDiscount, selectTotal, type OrderState } from './order.js';
const store = createStore(orderReducer, initialOrderState);
// View: reads the state and draws it; never changes the state directly
function renderOrderForm(state: OrderState): void {
console.log(
`render: ${state.book?.title ?? '-'} x${state.quantity} ` +
`total=${selectTotal(state).toFixed(2)} discount=${selectDiscount(state).toFixed(2)} status=${state.status}`,
);
}
store.subscribe(() => renderOrderForm(store.getState()));
// The side effect lives between actions, not in the reducer
async function submitOrder(): Promise<void> {
store.dispatch({ type: 'submitRequested' });
if (store.getState().status !== 'submitting') return;
const orderId = await Promise.resolve('ORD-1001');
store.dispatch({ type: 'submitSucceeded', orderId });
}
store.dispatch({ type: 'bookSelected', book: { id: 1, title: 'The Hobbit', price: 14 } });
store.dispatch({ type: 'quantityChanged', quantity: 2 });
store.dispatch({ type: 'quantityChanged', quantity: 3 });
store.dispatch({ type: 'quantityChanged', quantity: 50 });
store.dispatch({ type: 'quantityChanged', quantity: 99 });
await submitOrder();
await submitOrder();
$ node out/main.js
render: The Hobbit x1 total=14.00 discount=0.00 status=editing
render: The Hobbit x2 total=28.00 discount=0.00 status=editing
render: The Hobbit x3 total=37.80 discount=4.20 status=editing
render: The Hobbit x10 total=126.00 discount=14.00 status=editing
render: The Hobbit x10 total=126.00 discount=14.00 status=submitting
render: The Hobbit x10 total=126.00 discount=14.00 status=submitted
Eight dispatches, six renders, and none of them inconsistent. The moment the quantity is 3, the total and the discount appear correctly on the same line, because both are computed in one go from the same state; there is no listener order in between. When a quantity of 99 is requested, the reducer clamps it to 10, and since the quantity is already 10 it returns the same state and the render never runs. The second submitOrder call also ends quietly in the same way: the order has been submitted, so canSubmit is false.
Look at where the submission work sits. The HTTP (Hypertext Transfer Protocol) request is not in the reducer but between two actions: first submitRequested, then submitSucceeded when the response arrives. The reducer stays pure and the side effect lives outside it. In Redux this job falls to middleware and thunks, in NgRx to effects, in Elm to commands handed to the runtime. The names differ, the rule is the same.
TypeScript's discriminated union does extra work here. assertNever in the default branch makes the compiler check that every action type is handled. If you add { type: 'couponApplied'; code: string } to the union and leave the reducer alone:
$ npx tsc -p .
src/order.ts(38,32): error TS2345: Argument of type '{ type: "couponApplied"; code: string; }' is not assignable to parameter of type 'never'.
Adding a new action means a compile error in every reducer that forgets to handle it. On a large team, that hands the question "who handles this action?" over to the compiler.
A Short Bridge to Angular
This store is not tied to any framework. To connect it to a component in Angular 22, bridging the store to a signal is enough:
import { Component, DestroyRef, InjectionToken, computed, inject, signal } from '@angular/core';
import { createStore, type Store } from './store';
import { initialOrderState, orderReducer, selectTotal, type OrderAction, type OrderState } from './order';
export const ORDER_STORE = new InjectionToken<Store<OrderState, OrderAction>>('ORDER_STORE', {
providedIn: 'root',
factory: () => createStore(orderReducer, initialOrderState),
});
@Component({
selector: 'app-order-summary',
template: `
<p>Total: {{ total() }}</p>
<button (click)="addOne()">+1</button>
`,
})
export class OrderSummary {
private readonly store = inject(ORDER_STORE);
private readonly state = signal(this.store.getState());
protected readonly total = computed(() => selectTotal(this.state()));
constructor() {
// When the store changes the signal is updated and the OnPush component re-renders
const unsubscribe = this.store.subscribe(() => this.state.set(this.store.getState()));
inject(DestroyRef).onDestroy(unsubscribe);
}
protected addOne(): void {
this.store.dispatch({ type: 'quantityChanged', quantity: this.state().quantity + 1 });
}
}
When the store changes, the signal is updated, computed recalculates the total, and the component, which is OnPush by default in Angular 22, re-renders. Clicking the button does not change the state; it sends an action. The Vitest test for this wiring confirms the result Total: 28. In the next post we will build the same flow with signals themselves, without even needing this bridge.
The Role of Immutability
Returning a new object from the reducer every time looks wasteful at first. In return you get a very cheap question: "did anything change?" comes down to the comparison next === state. Had you changed the state in place, answering that question would mean walking every property of the object and every nested array. That is exactly what AngularJS's digest loop did.
The same idea shows up in Angular. OnPush change detection does not check a component if the reference of the value passed to its input has not changed; since OnPush is the default in Angular 22, calling push on an array and expecting the screen to update no longer works. Immutability here is not a matter of taste; it is how you make a change visible.
Declaring state types with readonly properties catches accidental mutation at compile time:
import type { OrderAction, OrderState } from './order.js';
// Bad example: changing the state in place
export function badReducer(state: OrderState, action: OrderAction): OrderState {
if (action.type === 'quantityChanged') state.quantity = action.quantity;
return state;
}
$ npx tsc -p .
src/bad-reducer.ts(5,50): error TS2540: Cannot assign to 'quantity' because it is a read-only property.
If this reducer compiled, it would produce two bugs at once: because it returns the same reference, the store notifies no listener and the screen stays stale; and because the previous state itself was changed, every tool that compares the "before" and "after" state (undo, time-travel debugging) gets the wrong answer. readonly protects you at compile time, not at runtime. That is why Redux Toolkit adds a middleware that catches mutations in development mode and has you write reducers with Immer, which looks like mutation but produces new objects behind the scenes; Immer freezes the state it produces by default.
The Cost of Boilerplate and When You Don't Need It
This approach comes with a bill, and ignoring it would not be honest. To show a value typed into an input on the screen, you write an action type, a union member, a case in the reducer and a selector. Classic Redux added action creators, action type constants and separate files on top. Dan Abramov's 2016 post "You Might Not Need Redux" was written for exactly this reason: accept these constraints if you need what they give you in return (changes traceable from one place, actions you can record and replay, pure reducer tests).
The order form on its own does not need a store. When state that stays inside one component (whether a dropdown is open, an input not yet submitted) moves into a global store, the gain is zero and the cost is four files for every change. A store pays off where the same state is read and changed by several distant components: the cart counter in the header, the cart contents in a side panel, the stock warning on a product card. The cascading update problem from the opening shows up exactly in that situation.
Also, unidirectional data flow and Redux are not the same thing. The flow is a principle: the state has a single owner, changes come in through a single door, and derived values are computed from the state. Redux is one implementation of that principle. In Angular you can apply the same principle with signals and a service, without a store library; that is the topic of the next post.
Common Pitfalls
Writing derived state into the state. Keeping total in the state and trying to update it on every action moves the glitch from the opening into the reducer. If an action updates quantity and forgets total, the two values drift apart. Keep the source data and compute the rest with selectors.
Side effects in the reducer. Sending an HTTP request, reading Date.now() or writing to localStorage inside a reducer makes it impure; called twice with the same state and action, it returns different results. If you need time or randomness, bring the value inside the action.
Naming actions like setters. Actions such as setTotal and setStatus turn the store into a fancy set of variables; the rules scatter to the callers. An action should describe an event, such as "the user changed the quantity".
Putting everything in one store. Single source of truth does not mean "all state must be global". Keeping whether a modal is open in the global store makes every place that uses that modal depend on the store. Put state next to the nearest common owner that reads it.
Not releasing the subscription. If you never call the function returned by subscribe, the listener of a destroyed component keeps living in the store. That is why DestroyRef.onDestroy is there in the Angular example.
A Small Experiment
★☆☆ In cascade.ts, remove the discount listener and compute the discount inside the total listener. How many renders happen? Then compare the same rule with selectDiscount in the store: in which one can you be sure the rule lives in a single place?
★★☆ Add coupon support to the store: a couponApplied action, a couponCode in the state, and a rule that takes 5 off the total for the code WELCOME5. Extend the union first and see where the compiler sends you; put the discount in a selector, not in the state. When the 10 percent discount and the coupon apply together, what should the order be, and where did you write that decision?
★★★ Add a small "history" to createStore that records every dispatched action and the state after it in an array. Starting from initialOrderState, run the recorded actions through the reducer again and rebuild the final state. Why does this replay break when you put Date.now() inside the reducer?
In this post we built unidirectional data flow without a framework, in its barest form: action, reducer, store and a view that listens to it. In the next post we build the same flow with Angular's own tools: data goes down from parent to child with input(), events go up with output(), derived state is computed with computed(), and the state lives in a service that holds signals.