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.
| Part | Lives in | Why |
|---|---|---|
| The answers and the current step | The store’s state | Every step reads them, and Undo has to be able to put them back. |
| Undo history | The state, filled by the reducer | Only the reducer sees every change, so only it can record each one. |
| Which save is in flight, and its id | The state | A reply is recognized by the id the reducer handed out. |
| The Saved badge | Derived from the state | Saved means the confirmed version equals the current one. Nobody sets it. |
| Sending the save | The store’s effect | The reducer decides; the effect does the network work and reports back as an action. |
| Which field has focus | The browser | Nothing 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.
Which save does “Saved” mean?
One shared draft
The page · 1 · You
- Name
- —
- —
- Home
- —
- Pet
- —
Status: saved
Writes, in order
- Nothing yet
Draft server
Holds: nothing yet
Saves in flight: 0
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.
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';
} 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.
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:
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.
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.
| What goes wrong | What the applicant sees | What the reducer build does | Backed by |
|---|---|---|---|
| Slow | Saving…, 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 order | Saved 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” |
| Failed | Save 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 asking | Nothing. 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 save | Unsaved, 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-save | On 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.
| Change | Shared draft | Actions and one reducer |
|---|---|---|
| A second way in: a “paste from last application” button | Writes 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 client | Change the autosave callback. | Change the effect. No difference here. |
| Change a rule: the email is checked before Next | Add the check to Next, and remember the paste button. | One branch in the reducer. |
| A second team adds an adoption-fee step | They 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 did | Plain prompt | Architecture prompt |
|---|---|---|
| The first save is held on the network until the second returns | page says "Saved" but the server has no email | page says "Saved"; server has both answers |
| Start over while the first save is held | when 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 hook | server has both answers; page says "Saved" | server has both answers; page says "Saved" |
| A save fails, then the next edit is saved | after the failure: "Save failed"; after the next edit: "Saved", server has both | after the failure: "Saving…"; after the next edit: "Saved", server has both |
| Undo after typing a name and an email | took back the email only | took back the email only |
| Undo after Next and one answer on page 2 | stayed 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.
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');
}
} 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.
The framework’s own door
Redux Toolkit’s
configureStoreadds 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’suseReducerhas no such check, but StrictMode calls your reducer twice in development, so a reducer with a side effect shows it (useReducer). Keep both on.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.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.
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
- State ownership in a component tree decides which component owns state; this lesson decides how it may change.
- Server state in the client is the same late-reply problem for data you read rather than write.
- Pure functions explains why a reducer that never changes its input can be tested, replayed, and undone.
- Command is the pattern under every action and every undo history.
- Functional core, imperative shell is the same split between the reducer and its effect, applied to a whole program.