Topic 4.2
A Reducer for Chat
In one line
A reducer is one pure function that takes the current chat and an action (send, token, done, error, stop) and returns the next chat. Moving every change through it keeps one source of truth and makes impossible states, like two answers streaming at once, impossible to reach.
Think of it like this
A bank teller with a rulebook. You never change your passbook yourself. You hand over a slip ("deposit 500", "withdraw 200") and the teller applies the rules and hands back the new passbook. A slip that breaks the rules (withdrawing from a closed account, the same slip twice) is handed back untouched, and nothing changes. A reducer is the teller: every change to the chat goes through it as a slip, and slips that make no sense are refused.
Words you'll meet
New words in this topic, in plain English. Come back here whenever one feels fuzzy.
- Reducer
- A pure function
(state, action) => newState. All changes to the chat go through it. - Action
- A plain object saying what happened, e.g.
{ type: "token", id, text }. - Dispatch
- The function you call to hand an action to the reducer. React's
useReducergives you one. - Pure function
- Same input, same output, no side effects: no network, no clock, no randomness.
- Optimistic update
- Showing the result of an action immediately, before the server confirms.
- Impossible state
- A combination that makes no sense, such as "done and streaming". A good design cannot represent it.
- Side effect
- Anything a function does besides returning a value: a request, a timer, writing to storage.
- currentId
- The id of the answer being written now. Actions for any other id are stale and ignored.
- Stale closure
- A callback that still holds an old copy of a variable from an earlier render.
Step by step
01The flag soup this replaces
Four separate variables describe one situation. Nothing stops them from disagreeing: isLoading and isStreaming can both be true, and an old error can sit beside a good answer.
const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [answer, setAnswer] = useState("");
const [error, setError] = useState("");
// every handler must remember to reset the right ones, in the right order02One object, one status
The same situation as one state value. status holds exactly one name, and busy is computed from it.
const initial = {
messages: [], // { id, role, content, status }
status: "idle", // idle | waiting | streaming | done | error
error: "",
currentId: null, // id of the answer being written, or null
};
const busy = state.status === "waiting" || state.status === "streaming";03Actions say what happened
Five slips cover a whole chat turn. Each carries only the facts: the ids are made by the caller, so the reducer stays pure.
{ type: "send", text, userId, assistantId }
{ type: "token", id, text }
{ type: "done", id }
{ type: "error", id, message }
{ type: "stop", id }04The rules, as a diagram
Each arrow is an action the reducer accepts. Anything not drawn is refused: no arrow leaves waiting for waiting on a second send, and no arrow goes from done back to streaming.
05Run a conversation without a screen
The first live example calls the reducer directly. Read each line: the word IGNORED marks an action the rules refused. Blank text, a second send while busy, a token with a wrong id, a second done and a token after stop are all refused, and the state does not change.
It also shows the two ends of a turn that has no tokens (done with nothing arrived gives empty) and a turn cut off after some text (error keeps the text).
06Hook it up to React
useReducer gives you the current state and dispatch. The handler makes the ids, dispatches send, does the request, and dispatches token, done or error as things happen. The second live example does exactly this.
const [state, dispatch] = useReducer(chatReducer, initial);
async function send(text) {
const assistantId = "a" + ++n, userId = "u" + n;
dispatch({ type: "send", text, userId, assistantId });
// ...fetch, then for every piece: dispatch({ type: "token", id: assistantId, text: piece })
// ...at the end: dispatch({ type: "done", id: assistantId })
}Try it yourself
- 1
Add a new rule
In the first example, add a
retrycase that is only accepted whenstate.status === "error": it setsstatusback toidleand clearserror. Add astep("retry", { type: "retry" })line after the firsterrorstep.Then call it once more when the chat is not in error. It prints IGNORED. A rule you add to the reducer works everywhere at once.
- 2
Dispatch from two places
In the second example, add a second button that calls
send("Where is ORD-1042?")directly. Click it twice quickly while an answer is streaming.The reducer refuses both extra sends (the list keeps its 2 messages), but look at the console: it prints an extra
done: a2anddone: a3line. The handler still made two more requests, and the reducer threw their pieces away. The reducer protects the state, not the network. Topic 4.7 closes that gap.
Code & diagrams
No screen, no network: only the rules. Every IGNORED line is a bug the reducer prevents. The last line shows the stopped message is exactly what it was.
Preview
Console
Nothing logged yet.Expected console output
send blank: IGNORED, chat idle, last message no messages
send: chat waiting, last message a1 pending ""
send while busy: IGNORED, chat waiting, last message a1 pending ""
token: chat streaming, last message a1 streaming "It has"
token: chat streaming, last message a1 streaming "It has shipped."
late token (wrong id): IGNORED, chat streaming, last message a1 streaming "It has shipped."
done: chat done, last message a1 done "It has shipped."
done again: IGNORED, chat done, last message a1 done "It has shipped."
send: chat waiting, last message a2 pending ""
token: chat streaming, last message a2 streaming "It is being pa"
error: chat error, last message a2 error "It is being pa"
send: chat waiting, last message a3 pending ""
done with no tokens: chat done, last message a3 empty ""
send: chat waiting, last message a4 pending ""
token: chat streaming, last message a4 streaming "Sure, "
stop: chat done, last message a4 stopped "Sure, "
token after stop: IGNORED, chat done, last message a4 stopped "Sure, "
messages: 8 | a4: {"id":"a4","role":"assistant","content":"Sure, ","status":"stopped"}The script asks three questions: a normal one, one whose stream is cut after two pieces, and one that crashes the server. The (server) line is the server's own log. After the crash the chat is in the error state and the form is ready again.
Preview
Console
Nothing logged yet.Expected console output
button disabled while busy: true
done: a1 "ORD-1042 shipped yesterday and arrives tomorrow."
error: a2 "ORD-1043 is being packed" | network error
(server) Error: model provider is unreachable
error: a3 "" | Mitra could not answer. Please try again.
final: Status: error | 6 messagesBreak it on purpose
Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.
Break #1
Accept a token for any id
In the first example, delete the line if (action.id !== state.currentId) return state; inside case "token": only.
Myth vs fact
Myth
A reducer is just a fancier setState.
Fact
setState accepts any value. A reducer accepts only actions it understands and decides what they mean, so illegal changes can be refused in one place.
Myth
The reducer should call the API.
Fact
A reducer must be pure. Requests, timers and storage belong in event handlers or effects, which report back with dispatch.
Myth
If the reducer ignores send while busy, a double click is handled.
Fact
The state is protected but the handler already started a second request. You need a guard before the request (Topic 4.7).
Myth
Redux is required for reducers.
Fact
useReducer is built into React. The idea is the same, and nothing else needs installing.
Pro corner
Extra depth for experienced readers. New to this? Skip it for now and come back later.
- ▸
Name actions after what *happened* (
token,error), not after what to change (setStatusStreaming). Then the reducer can change what atokenmeans later without touching the callers. - ▸
patchmaps over all messages for every token. That is fine for hundreds of messages. For very long chats, store messages in an object by id plus an ordered list of ids, so one token touches one entry. - ▸
Because the reducer is pure and the ids come in with the action, a bug report can be a list of actions. Log the actions in development, paste them into a test, and the bug repeats exactly.
- ▸
Keep unknown actions returning the same
state. React skips the render when the returned object is identical, which matters when tokens arrive faster than frames.
Remember this
- 1
The problem this solves: With several
useStatevariables (isLoading,isStreaming,answer,error) every handler has to set the right combination, in the right order, and a streaming callback may hold an old copy of them. One missedsetError("")and an old error stays on screen beside a new answer. A reducer puts all the rules in one place, so the screen can never show a combination the rules don't allow. - 2
*Recall from Topic 4.1: every message has an
idand astatus(pending, streaming, done, error, stopped, empty). The reducer below changes exactly those fields.* A reducer is a function(state, action) => newState. State is the whole chat in one object: the messages, thestatus(idle,waiting,streaming,doneorerror), an error message, and the id of the answer being written now (currentId). An action is a plain object that says *what happened*, not what to set:{ type: "token", id: "a1", text: " shipped." }. The reducer decides what that means for the state. - 3
The five actions.
send(the user pressed send): add the user's message and an empty assistant placeholder, go towaiting.token(a piece arrived): append to the placeholder, go tostreaming.done(the stream ended normally): mark the messagedone, go todone.error(a failure): keep any text, mark the messageerror, store a message for the user.stop(the user pressed stop): keep the text, mark itstopped. Every action that updates an answer carries the messageidit is about. - 4
Optimistic update. On
send, the user's own message appears at once, before the server has said anything. It is "optimistic" because the screen assumes the send will work. That's right for a chat: the user sees their question the instant they press the button, and if the request later fails, only the answer is marked as failed, not the question. - 5
Making impossible states impossible. The reducer *refuses* actions that don't fit.
sendwhile the chat iswaitingorstreamingis ignored, so two answers can't stream at once.token,done,errororstopwhoseidisn't the current answer is ignored, so a late piece from an old answer can't write into a new one.tokenafterstopis ignored, so a stopped answer stays stopped. Refusing is done byreturn state;(the same object), which also tells React nothing changed. - 6
Reducers are pure. The same state and action must always give the same result, and the function must not do anything else: no
fetch, noDate.now(), noMath.random(), nolocalStorage. That's why the caller makes the ids and passes them in the action. Pure functions need no setup to test: the first live example runs a whole conversation as plain calls, with no screen and no network, and prints the state after each one. (React may even call your reducer twice in development to check this.) - 7
Side effects live outside. The
fetch, the reading loop and theAbortControllerstay in your event handler; the handler only *reports* what happens by callingdispatch(action). One warning: the reducer ignoring asenddoes not stop the handler from making the request. A refused send must also stop the request, which needs a guard in the handler (Topic 4.7). - 8
Edge cases the reducer owns. A stream that ends with no text:
donemarks the messageemptyso the screen can say "(no answer)". An error after some text: the partial text stays, the status sayserror. Whether a chat is busy is derived fromstatus, never stored as a second variable.
Explain it without notes
What is a reducer, and why is it a good fit for chat state?
Why do token, done, error and stop carry an id?
What is an optimistic update, and where does Mitra use one?
Why can't a reducer call fetch?
Practice
Add a clear action to the reducer that empties the conversation. It must be ignored while the chat is busy.
Write a line that proves a token for an unknown id leaves the state object unchanged (the very same reference).
Trade-offs
- ↔
A reducer adds a little ceremony (actions, a switch) compared with
useState. For one input and one answer that is more than needed. It pays for itself as soon as there are three or more kinds of change to the same data. - ↔
Refusing actions silently keeps the state safe but hides bugs. In development, log ignored actions (
console.warn) so a refusedsendor a staletokenis noticed rather than lost.
Done when you can
I can write a reducer with send, token, done, error and stop actions.
I can explain why the reducer ignores a token whose id is not current.
I can show a user message immediately and an empty answer placeholder (optimistic update).
I can keep side effects (fetch, abort, storage) out of the reducer.
I can test a reducer with plain function calls and read the printed states.