← Architecture
Frontend applications One way in

Unidirectional data flow

Every change enters as an action.

You have written a reducer, used Redux, or kept a Svelte store with a few functions that change it. The part that matters is not the library. It is that every change, including the reply to a request, arrives through one door, so you can say what the state was, what happened, and what it became. Let’s ask an agent for a form that autosaves and see what happens when a reply is late.

TypeScriptGoOne wizard, two builds, two recorded agent runs.

01 / The prompt

“Build me an adoption application form that saves as you type.”

A small pet rescue wants an application wizard: your details, your home, the pet you are asking about, and a review page. It should save the draft as people type, so nobody loses a half-finished application, and it should have an Undo button. What comes back works. You type, a moment later the badge says Saved, Undo takes something back.

The demo never shows two saves in flight at once. On a real connection it happens all the time: you type your name, the autosave goes out, you type your email, another autosave goes out, and the network delivers the replies in whatever order it likes. React’s documentation describes the same race for search results: “there is no guarantee about which order the responses will arrive in”, and a late response “will call setResults() last”, showing the wrong results (You Might Not Need an Effect, fetched 23 September 2026). For a form, the wrong result is a badge that says Saved over a draft the server does not have.

The question the prompt never answered is where changes come from. If an input can write the draft, a callback can write the status, and Next can take a snapshot, then no one place knows what happened in what order, and neither Saved nor Undo can be trusted.

02 / Name the shape

Every change enters as an action.

Unidirectional data flow means state changes travel one way: something happens, it is described as an action, one reducer turns the current state and that action into the next state, and the view renders from the result. The view never writes state back. Redux states it as three principles: a single store, “The only way to change the state is to emit an action, an object describing what happened”, and “you write pure reducers” (Three Principles). useReducer and a Svelte store with a dispatch are the same shape without the library.

Anything that changes the state, including a server’s reply, is an action through one reducer. The view only reads.

Here is where each part of the wizard lives.

Where each part of the wizard lives
PartLives inWhy
The answers and the current stepThe store’s stateEvery step reads them, and Undo has to be able to put them back.
Undo historyThe state, filled by the reducerOnly the reducer sees every change, so only it can record each one.
Which save is in flight, and its idThe stateA reply is recognized by the id the reducer handed out.
The Saved badgeDerived from the stateSaved means the confirmed version equals the current one. Nobody sets it.
Sending the saveThe store’s effectThe reducer decides; the effect does the network work and reports back as an action.
Which field has focusThe browserNothing else needs it. Not every value belongs in the store.

Words to put in a prompt or a review

Action
A plain object that says what happened: an edit, a click, a reply.
Reducer
A pure function from the state and an action to the next state.
Dispatch
The one door into the store: hand it an action.
Effect
Work outside the reducer, such as a request, that reports back with an action.
Request id
The tag that lets a reply say which request it answers.
Derived state
Worked out from the state, like the Saved badge, and never stored beside it.
Is two-way binding the opposite?bind:value, v-model, and forms

Not by itself. bind:value in Svelte or v-model in Vue is shorthand for “read this value, and write it back when the input changes”. That is fine for state one component owns. It becomes the shared draft in this lesson when the thing being bound is an object many components and callbacks hold, so a write can come from anywhere and nothing records it. Bind to local state, and send changes to shared state as actions.

03 / Follow one save

Watch the same typing on two wizards.

First the build where inputs write a shared draft and each save’s reply sets the badge. Then the build where everything is an action. The same two answers, the same two autosaves, the same replies in the wrong order. Then Undo, on both. Step through, or open Try it and control the network yourself.

Unidirectional data flow

Which save does “Saved” mean?

One shared draft

The page · 1 · You

Name
—
Email
—
Home
—
Pet
—

Status: saved

Writes, in order

  1. Nothing yet

Draft server

Holds: nothing yet

Saves in flight: 0

 

01/ 04
A late reply, shared draft

Ana types her name.

The input writes it straight into the draft object.

Reduced motion: choose a scene to see its completed state.

Read this scene

The input writes it straight into the draft object.

One shared draft. Ana types her name. Page step you; status saved. Server holds nothing yet; 0 saves in flight.

Watch restarts the story when you come back. Step through keeps your step. Try it starts both builds fresh each time you open it.

04 / Read the shape

The reducer decides. The effect reports back.

Basic form is the reducer: the only function that makes a new state. In the wild is the store that runs it and sends the save it asked for. At the call site the page renders from the state and dispatches everything else, including the autosave timer.

Notice what the reducer does with save-finished: it compares the reply’s id with the save it is waiting for. That one comparison is what the shared draft never had.

The reducer: the one place the wizard’s state changes. An edit, Next, Undo, the autosave timer, and a save’s reply are all actions. A reply for a save it did not send last is ignored. The status is worked out from the state, never set.

TypeScriptReading
wizard.ts
export type Step = 'you' | 'home' | 'pet' | 'review';
export type Field = 'name' | 'email' | 'homeType' | 'petId';
export type Answers = Record<Field, string>;
export type Status = 'saved' | 'unsaved' | 'saving' | 'failed';

export type Action =
	| { type: 'edit'; field: Field; value: string }
	| { type: 'next' }
	| { type: 'back' }
	| { type: 'undo' }
	| { type: 'reset' }
	| { type: 'autosave' }
	| { type: 'save-finished'; id: number; ok: boolean };

export interface WizardState {
	readonly step: Step;
	readonly answers: Answers;
	readonly past: readonly { step: Step; answers: Answers }[];
	readonly version: number;
	readonly savedVersion: number;
	/** The save the page should send, if any. Its id is how its reply is recognized. */
	readonly request: { id: number; version: number; answers: Answers } | null;
	readonly queued: boolean;
	readonly failed: boolean;
	readonly error: string | null;
	readonly lastId: number;
}

export const steps: readonly Step[] = ['you', 'home', 'pet', 'review'];
const required: Record<Step, Field[]> = {
	you: ['name', 'email'],
	home: ['homeType'],
	pet: ['petId'],
	review: []
};
export const empty: Answers = { name: '', email: '', homeType: '', petId: '' };

export const initialState: WizardState = {
	step: 'you',
	answers: empty,
	past: [],
	version: 0,
	savedVersion: 0,
	request: null,
	queued: false,
	failed: false,
	error: null,
	lastId: 0
};

const same = (a: Answers, b: Answers) =>
	(Object.keys(a) as Field[]).every((field) => a[field] === b[field]);

/** Asks for a save of what is on screen now. The page sends it; the reducer only decides. */
function send(state: WizardState): WizardState {
	const id = state.lastId + 1;
	return {
		...state,
		lastId: id,
		queued: false,
		request: { id, version: state.version, answers: state.answers }
	};
}

/** The one place the wizard's state changes. Pure: same state and action, same result. */
export function reduce(state: WizardState, action: Action): WizardState {
	const at = { step: state.step, answers: state.answers };
	const move = (by: number) => steps[steps.indexOf(state.step) + by];
	switch (action.type) {
		case 'edit':
			if (state.answers[action.field] === action.value) return { ...state, error: null };
			return {
				...state,
				past: [...state.past, at],
				answers: { ...state.answers, [action.field]: action.value },
				version: state.version + 1,
				error: null
			};
		case 'next': {
			const gaps = required[state.step].filter((field) => !state.answers[field].trim());
			if (gaps.length) return { ...state, error: `missing: ${gaps.join(', ')}` };
			if (state.step === 'review') return { ...state, error: null };
			return { ...state, past: [...state.past, at], step: move(1), error: null };
		}
		case 'back':
			if (state.step === 'you') return { ...state, error: null };
			return { ...state, past: [...state.past, at], step: move(-1), error: null };
		case 'undo': {
			const last = state.past.at(-1);
			if (!last) return { ...state, error: null };
			return {
				...state,
				past: state.past.slice(0, -1),
				step: last.step,
				answers: last.answers,
				version: same(last.answers, state.answers) ? state.version : state.version + 1,
				error: null
			};
		}
		case 'reset':
			// Start over. A save already sent is forgotten here, not canceled on the server.
			return {
				...state,
				step: 'you',
				answers: empty,
				past: [],
				version: state.version + 1,
				request: null,
				queued: false,
				failed: false,
				error: null
			};
		case 'autosave':
			if (state.request) return { ...state, queued: true, error: null };
			if (state.version === state.savedVersion) return { ...state, error: null };
			return send({ ...state, error: null });
		case 'save-finished': {
			// A reply for a save this state did not send is ignored, not trusted.
			if (!state.request || action.id !== state.request.id) return state;
			const settled: WizardState = action.ok
				? { ...state, request: null, savedVersion: state.request.version, failed: false }
				: { ...state, request: null, failed: true };
			return settled.queued && settled.version !== settled.savedVersion
				? send(settled)
				: { ...settled, queued: false };
		}
	}
}

export function status(state: WizardState): Status {
	if (state.request) return 'saving';
	if (state.version === state.savedVersion) return 'saved';
	return state.failed ? 'failed' : 'unsaved';
}
GoAlongside
main.go
type Answers struct {
	Name     string `json:"name"`
	Email    string `json:"email"`
	HomeType string `json:"homeType"`
	PetID    string `json:"petId"`
}

func (a *Answers) set(field, value string) {
	switch field {
	case "name":
		a.Name = value
	case "email":
		a.Email = value
	case "homeType":
		a.HomeType = value
	case "petId":
		a.PetID = value
	}
}

func (a Answers) get(field string) string {
	return map[string]string{"name": a.Name, "email": a.Email, "homeType": a.HomeType, "petId": a.PetID}[field]
}

var Steps = []string{"you", "home", "pet", "review"}
var required = map[string][]string{"you": {"name", "email"}, "home": {"homeType"}, "pet": {"petId"}, "review": {}}

type Action struct {
	Type  string // edit, next, back, undo, autosave, save-finished
	Field string
	Value string
	ID    int
	OK    bool
}

type snapshot struct {
	Step    string
	Answers Answers
}

type Request struct {
	ID      int
	Version int
	Answers Answers
}

type State struct {
	Step         string
	Answers      Answers
	Past         []snapshot
	Version      int
	SavedVersion int
	Request      *Request // the save the page should send; its ID recognizes the reply
	Queued       bool
	Failed       bool
	Error        string
	LastID       int
}

func Initial() State { return State{Step: "you"} }

func index(step string) int {
	for i, s := range Steps {
		if s == step {
			return i
		}
	}
	return 0
}

func send(s State) State {
	s.LastID++
	s.Queued = false
	s.Request = &Request{ID: s.LastID, Version: s.Version, Answers: s.Answers}
	return s
}

// Reduce is the one place the wizard's state changes. It never edits the
// state it was given: Past is copied before it grows.
func Reduce(s State, a Action) State {
	at := snapshot{s.Step, s.Answers}
	past := append(append([]snapshot{}, s.Past...), at)
	if a.Type != "save-finished" {
		s.Error = ""
	}
	switch a.Type {
	case "edit":
		if s.Answers.get(a.Field) == a.Value {
			return s
		}
		s.Past = past
		s.Answers.set(a.Field, a.Value)
		s.Version++
	case "next":
		var gaps []string
		for _, f := range required[s.Step] {
			if strings.TrimSpace(s.Answers.get(f)) == "" {
				gaps = append(gaps, f)
			}
		}
		if len(gaps) > 0 {
			s.Error = "missing: " + strings.Join(gaps, ", ")
		} else if s.Step != "review" {
			s.Past = past
			s.Step = Steps[index(s.Step)+1]
		}
	case "back":
		if s.Step != "you" {
			s.Past = past
			s.Step = Steps[index(s.Step)-1]
		}
	case "undo":
		if len(s.Past) > 0 {
			last := s.Past[len(s.Past)-1]
			s.Past = s.Past[: len(s.Past)-1 : len(s.Past)-1]
			if last.Answers != s.Answers {
				s.Version++
			}
			s.Step, s.Answers = last.Step, last.Answers
		}
	case "reset":
		// Start over. A save already sent is forgotten here, not canceled on the server.
		s.Step, s.Answers, s.Past = "you", Answers{}, nil
		s.Version++
		s.Request, s.Queued, s.Failed = nil, false, false
	case "autosave":
		if s.Request != nil {
			s.Queued = true
		} else if s.Version != s.SavedVersion {
			s = send(s)
		}
	case "save-finished":
		// A reply for a save this state did not send is ignored, not trusted.
		if s.Request == nil || a.ID != s.Request.ID {
			return s
		}
		if a.OK {
			s.SavedVersion = s.Request.Version
			s.Failed = false
		} else {
			s.Failed = true
		}
		s.Request = nil
		if s.Queued && s.Version != s.SavedVersion {
			s = send(s)
		}
		s.Queued = false
	}
	return s
}

func Status(s State) string {
	switch {
	case s.Request != nil:
		return "saving"
	case s.Version == s.SavedVersion:
		return "saved"
	case s.Failed:
		return "failed"
	}
	return "unsaved"
}
The behavior these examples promiseChecked by 13 shared scenarios in TypeScript and Go
  • An edit that changes a field pushes the current step and answers onto the history and bumps the version. Next checks the step’s required fields; Back and Next are undoable too.
  • Undo pops one entry. If it changes the answers, that is a new version, which needs saving.
  • Autosave sends a save only when the version is unsaved and none is in flight. If one is in flight, the save is queued and sent when the reply arrives, if there is still something to save.
  • A reply is ignored unless its id is the save in flight. Start over forgets the save in flight; its reply, when it comes, is ignored.
  • The status is saving while a save is out, saved when the confirmed version is the current one, failed after a failed save, and unsaved otherwise.

Every expected result in the shared cases was produced by a separate model written from these rules, kept beside the examples in model/cases.py, not copied from either implementation. It models the shared draft too, and the draft server, which stores a successful save whatever the page makes of the reply.

The shared draftThe other build, in full

Inputs write the draft object. Next takes a snapshot for Undo. The autosave sends whatever is there and the reply sets the status, for whichever save it answers. No line is wrong on its own.

shared.ts · one shared draft
export class SharedDraft {
	step: Step = 'you';
	answers: Answers = { ...empty };
	status: Status = 'saved';
	error: string | null = null;
	#snapshots: { step: Step; answers: Answers }[] = [];

	constructor(private server: DraftServer) {}

	/** An input writes straight into the draft it was given. */
	set(field: Field, value: string) {
		this.error = null;
		this.answers[field] = value;
		this.status = 'unsaved';
	}

	next() {
		const gaps = required[this.step].filter((field) => !this.answers[field].trim());
		this.error = gaps.length ? `missing: ${gaps.join(', ')}` : null;
		if (gaps.length || this.step === 'review') return;
		this.#snapshots.push({ step: this.step, answers: { ...this.answers } });
		this.step = steps[steps.indexOf(this.step) + 1];
	}

	back() {
		this.error = null;
		if (this.step !== 'you') this.step = steps[steps.indexOf(this.step) - 1];
	}

	/** Undo restores the last snapshot, which Next took. */
	undo() {
		this.error = null;
		const last = this.#snapshots.pop();
		if (!last) return;
		this.step = last.step;
		this.answers = { ...last.answers };
		this.status = 'unsaved';
	}

	startOver() {
		this.error = null;
		this.step = 'you';
		this.answers = { ...empty };
		this.#snapshots = [];
		this.status = 'unsaved';
	}

	/** The autosave timer sends what is there now, and the reply sets the status. */
	autosave(id: number) {
		this.error = null;
		this.status = 'saving';
		this.server.put(id, this.answers, (ok) => {
			this.status = ok ? 'saved' : 'failed';
		});
	}
}
Reading the TypeScriptA discriminated union and a pure switch

Action is a union on type, so the reducer’s switch gets each action’s own fields. Every branch returns a new object with spread syntax and never assigns into state, which is what lets the spec run the same actions twice and get equal results. observe is a devtools-style hook the lesson’s lab uses to print the action log.

Reading the GoValue receivers and a copied slice

Reduce takes and returns State by value, so the caller’s state is never changed. The history slice is copied before it grows, because two states sharing one backing array would let a later append overwrite an earlier state’s history. DraftServer calls back rather than returning a channel, which keeps the tests deterministic.

Run it yourselfNo dependencies

Copy the complete TypeScript file and run node --experimental-strip-types wizard.ts with Node 22.18 or later. For Go, save main.go next to this go.mod and run go run .. Both print:

go.mod
module unidirectional-data-flow

go 1.22
two edits, second save waits: saving · server has nothing
first reply, next save sent: saving · server has Ana Li, (no email)
second reply: saved · server has Ana Li, ana@example.com
edit, then undo: name Ana Li · unsaved

The last line says unsaved on purpose: Undo made a new version, and nothing has confirmed it yet, even though it matches what the server holds.

05 / Review the agent’s diff

“Fixed the flickering Saved badge.”

A badge that flickers between Saving and Saved is a real complaint, and a status set directly from the reply looks like the obvious fix. Read which save that reply belongs to before you decide.

The agent’s pull request

“Fixed the flickering Saved badge: the save effect sets the status directly when the reply lands. Tests pass.”

// useAdoptionDraft.ts
			(added)const [saveStatus, setSaveStatus] = useState<Status>('saved');
			
			useEffect(() => {
			  if (!request) return;
			(added)  setSaveStatus('saving');
			  putDraft(request.answers, controller.signal).then(
			(removed)    (ok) => dispatch({ type: 'save-finished', id: request.id, ok }),
			(added)    (ok) => setSaveStatus(ok ? 'saved' : 'failed'),
			    …
			  );
			(removed)  return () => controller.abort();
			}, [request]);
			
			(removed)return { state, dispatch, saveStatus: status(state) };
			(added)return { state, dispatch, saveStatus };
			
You are reviewing this change. What do you do?

06 / How it fails

An autosave fails in more ways than “the request failed”.

Here is each way a save can go wrong for this wizard, what the applicant sees, and what the reducer build does.

Failure modes of one autosave
What goes wrongWhat the applicant seesWhat the reducer build doesBacked by
SlowSaving…, for as long as it takes.Keeps one save in flight and queues the next.Case “an edit while a save is in flight”
Out of orderSaved only when the server has what is on screen.Never has two saves out, so the server cannot be overwritten by an older draft.Case “an older save finishes last”
FailedSave failed, until the next change saves.Records the failure; the next autosave sends again.Cases “a save fails, then the next one works”, “a failed save with nothing new”
Answers a question nobody is askingNothing. The badge stays Saving… for the new draft.Ignores a reply whose id is not the save in flight.Case “start over while a save is in flight”
Undo meets a saveUnsaved, then Saved again.Treats the undone state as a new version and saves it.Case “undo an edit made after a save”
The tab closes mid-saveOn return, whatever the server confirmed.Nothing the page can do. The badge said Saving…, which was true.Authored

The server side has a matching guard this lesson leaves out: a version number on each save, so the server refuses an older draft even if two pages send one. Optimistic concurrency covers it, and Race conditions in UI covers the ignore-the-stale-reply pattern outside a reducer.

07 / Is it worth it?

You pay in ceremony. Here is what it buys.

Actions, a reducer, and an effect that reports back are more lines than an input that writes a field. Hold both builds up against the changes a form like this always gets.

The same four changes, made to each build
ChangeShared draftActions and one reducer
A second way in: a “paste from last application” buttonWrites the draft; Undo cannot take it back.A new action; Undo and autosave work on it with no extra code.
Replace fetch with a retrying clientChange the autosave callback.Change the effect. No difference here.
Change a rule: the email is checked before NextAdd the check to Next, and remember the paste button.One branch in the reducer.
A second team adds an adoption-fee stepThey get the draft object and can write anything.They add actions; the reducer’s tests cover what those actions may change.

Before you move a form to this shape, decide what you will look at:

  • Drafts that differ from what the applicant last saw as Saved. Log the version the page showed as saved beside the version the server stored. The accepted result is zero mismatches.
  • Writers to shared state. Count the places that assign into the draft or set the status outside the reducer. The target is zero.
  • Support requests about lost answers, before and after. It is the number the rescue actually cares about.

This page did not run the form for a real rescue, so it has no numbers to give you. Decide the questions before the change; the answers come after.

08 / Ask for it

Two prompts, two wizards, one checker.

We sent two agents the same request for this wizard at the same time, both running Claude Sonnet. The shared prompt fixed the server’s draft endpoint and gave it a test hook to make replies late or failed. The architecture prompt added one store changed only by actions, undo one action at a time, one save in flight with replies tagged by save id, and “Saved means the server confirmed what is on screen”. Then a script drove each build in Chromium, holding saves on the network to make their replies late.

What the checker found, run 2026-09-23
What the checker didPlain promptArchitecture prompt
The first save is held on the network until the second returnspage says "Saved" but the server has no emailpage says "Saved"; server has both answers
Start over while the first save is heldwhen the old reply lands: "Saved", server has name "Ana Li"; 1.5 s later: page shows "Bo" and says "Saved"; server has name "Ana Li"when the old reply lands: "Saved", server has name "Bo"; 1.5 s later: page shows "Bo" and says "Saved"; server has name "Bo"
Late replies set through the server’s own test hookserver has both answers; page says "Saved"server has both answers; page says "Saved"
A save fails, then the next edit is savedafter the failure: "Save failed"; after the next edit: "Saved", server has bothafter the failure: "Saving…"; after the next edit: "Saved", server has both
Undo after typing a name and an emailtook back the email onlytook back the email only
Undo after Next and one answer on page 2stayed on Your home; home type ""stayed on Your home; home type ""

The first checker run found both builds truthful, and that was the checker’s fault. The servers’ test hook delayed replies, but the two saves still came back in the order they were sent, so no reply was ever late. From the second run the checker holds the first save on the network, before it reaches the server, until the second has come back. That is the slow connection the lesson is about.

Held that way, the plain build said Saved while the server had no email. After Start over, it showed Bo, said Saved, and the server held Ana Li’s draft. Its doSave() sets the badge from whatever reply arrives, for whichever save it answers: the shared draft from section 03, built by an agent from a prompt that never said what Saved means.

public/app.js · plain prompt
async function doSave() {
  setSaveStatus('Saving…');
  try {
    const res = await fetch('/api/draft', {
      method: 'PUT',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(state),
    });
    if (!res.ok) throw new Error('save failed');
    setSaveStatus('Saved');
  } catch (e) {
    setSaveStatus('Save failed');
  }
}
public/reducer.js · architecture prompt
case 'SAVE_SUCCESS': {
  if (action.id !== state.saveId) return state; // stale reply, ignored
  const lastSavedAnswers = action.answers;
  const dirty = !answersEqual(state.answers, lastSavedAnswers);
  return {
    ...state,
    saving: false,
    lastSavedAnswers,
    dirty,
    status: dirty ? 'unsaved' : 'saved',
  };
}

The architecture build ignored a reply by id and said Saved only once the confirmed answers matched the screen. The line that did the work is the one the plain prompt lacked: a save’s reply comes back as an action carrying that save’s id, and Saved means the server has confirmed the exact answers on screen.

Undo did not split the builds. The plain agent kept a history of field edits on its own, so the snapshot-on-Next undo in section 03 is the lesson’s example, not something this run built.

How the runs were made and checkedOne run each, four checker runs
  • Both agents received the prompts word for word, in fresh contexts, at the same time. The only differences were the Architecture block and the output folder.
  • The files each agent wrote are kept byte for byte, with checksums. The checker restores them, starts a fresh server for every question, and drives Chromium with Playwright.
  • Two checker mistakes are kept. Run 1 could not make a reply late, as above. Run 3 read the server and then the badge, and a save landing between the reads made the architecture build look wrong; run 4 reads the badge before and after and retries until it holds still. A repeat of run 4 gave the same verdicts.
  • Neither agent opened its form in a browser; both tested their servers with curl. The architecture agent left its test server running and reported that it had stopped it; the authoring session found and stopped it hours later.
  • One run of each prompt is a sample, not a measurement of a model.

09 / Hold it there

Make a second writer visible.

The shape erodes one convenient line at a time: a callback that sets the status, an input that writes the draft. Three kinds of check make that line hard to add quietly.

  1. The framework’s own door

    Redux Toolkit’s configureStore adds a development check that throws when state is mutated inside or outside a reducer, and a check that actions and state are serializable (getDefaultMiddleware, fetched 23 September 2026). React’s useReducer has no such check, but StrictMode calls your reducer twice in development, so a reducer with a side effect shows it (useReducer). Keep both on.

  2. An import rule an agent cannot argue with

    Components and callbacks import the store’s dispatch, never the state object or the reducer. This rule, run with dependency-cruiser 18.3 against a five-file copy of the shape, flagged the autosave callback that wrote the status directly and nothing else. Enforcement layer runs rules like this against real code.

    .dependency-cruiser.cjs
    // .dependency-cruiser.cjs
    module.exports = {
    	forbidden: [
    		{
    			name: 'only-the-store-writes-the-draft',
    			comment: 'Components dispatch actions. Only the store module imports the reducer and the state.',
    			severity: 'error',
    			from: { pathNot: '^src/wizard/store\\.' },
    			to: { path: '^src/wizard/(reducer|state)\\.' }
    		}
    	]
    };
    depcruise output
      error only-the-store-writes-the-draft: src/wizard/useAutosave.js → src/wizard/state.js
    
    x 1 dependency violations (1 errors, 0 warnings). 5 modules, 4 dependencies cruised.
  3. A check on what actually happens

    An import rule cannot see a status set inside the store’s own file. So test the claim the badge makes: after any sequence of edits and replies, Saved means the server holds what is on screen. The lesson’s spec runs that for every shared case, and the checker in section 08 does it to the recorded builds with real late replies.

    check-runs.mjs
    async function truth(page) {
    	for (let attempt = 0; ; attempt++) {
    		const says = await saveStatus(page);
    		const shown = { name: await value(page, 'name'), email: await value(page, 'email') };
    		const stored = await draft();
    		if ((await saveStatus(page)) !== says && attempt < 10) {
    			await wait(100);
    			continue;
    		}
    		const holds = stored.name === shown.name && (stored.email ?? '') === shown.email;
    		return { says, shown, stored, saysSaved: /^saved$/i.test(says), truthful: !/^saved$/i.test(says) || holds };
    	}
    }
    
Where this lives in React and SvelteEvery useReducer already follows this rule. An autosave effect is where it gets tested.

Where it already is in your components

Any component with useReducer, any Redux or Zustand store with actions, and any Svelte store you only change through its own functions is this shape. So is a form library that hands you setValue instead of the values object. The Undo in a text editor you have used is a history of actions a reducer applied.

When you have to own it

The day you add an effect that talks to a server. The reducer is easy; the discipline is in the effect. It does the request the state asked for, and reports back with an action tagged by that request’s id, so the reducer can ignore a reply that belongs to an older request. In React that effect depends on the request object in state; in Svelte it is an $effect reading it. Both abort on cleanup, and neither ever sets the status itself.

useReducer, or a Svelte state plus dispatch, over the lesson’s reducer. Inputs and buttons only dispatch; the status is derived.

ReactAlready in your code
YourDetails.tsx
import { useReducer, type ChangeEvent } from 'react';
import { initialState, reduce, status, type Field } from './wizard';

// Every change is an action. useReducer hands the current state and the
// action to reduce, and the component renders whatever reduce returns.
export function YourDetails() {
	const [state, dispatch] = useReducer(reduce, initialState);
	const edit = (field: Field) => (event: ChangeEvent<HTMLInputElement>) =>
		dispatch({ type: 'edit', field, value: event.target.value });

	return (
		<form onSubmit={(event) => event.preventDefault()}>
			<label>
				Name <input value={state.answers.name} onChange={edit('name')} />
			</label>
			<label>
				Email <input value={state.answers.email} onChange={edit('email')} />
			</label>
			{state.error && <p role="alert">{state.error}</p>}
			<button
				type="button"
				onClick={() => dispatch({ type: 'undo' })}
				disabled={!state.past.length}
			>
				Undo
			</button>
			<button type="button" onClick={() => dispatch({ type: 'next' })}>
				Next
			</button>
			<p aria-live="polite">{status(state)}</p>
		</form>
	);
}

10 / Make the call

Route changes through one door when order matters.

A form with no autosave, no undo, and one submit button does not need a reducer. Local state and a submit handler are the shorter, clearer program. Reach for actions and a reducer when changes can arrive in an order you do not control, such as replies, other tabs, or live updates, or when you need to replay or undo them.

Reopen the decision when someone adds a callback that sets state directly, when Undo misses a kind of change, or when a status can be wrong for a moment. Each says a change found another way in.

Take it with you

Explain it without saying “unidirectional”: “Everything that happens to the form, including a reply from the server, is written down as an event and handed to one function that decides the next state. The screen just shows that state.” Then find the last autosave you shipped and check which save its Saved badge is about.

Paste into your next prompt, and fill in the blanks

All <feature> state lives in one store: <the fields, the history,
the save status>. It changes only by dispatching actions to one
reducer; components and callbacks never write state directly.
An async result, such as <a save's reply>, comes back as an action
carrying the id of the request it answers. The reducer ignores a
reply for any request other than the latest one it sent.
<At most one save> is in flight at a time.
<"Saved"> means the server confirmed the exact <answers> on screen.
Undo steps back one action at a time.
Connections to follow nextRelated lessons

Take the wizard into your editor. Add a “paste from my last application” button, and make it one action that Undo can take back.

Back to architecture →