Command Palette

Search for a command to run...

Hectal
PHASE 4Intermediate ~30 min· topic 2 of 8

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 useReducer gives 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.

flags.jswhole filejs
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 order

02One object, one status

The same situation as one state value. status holds exactly one name, and busy is computed from it.

state.jswhole filejs
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.

actions.jswhole filejs
{ 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.

The rules, as a diagramdiagram
Rendering diagram…

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.

App.jsxwhole filejsx
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. 1

    Add a new rule

    In the first example, add a retry case that is only accepted when state.status === "error": it sets status back to idle and clears error. Add a step("retry", { type: "retry" }) line after the first error step.

    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. 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: a2 and done: a3 line. 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

The reducer, run without a screen

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

Press Run to see the page.

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 reducer driving a real streamed chat

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

Press Run to see the page.

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 messages

Break 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.

terminal
$ Console
── what you'll see ──
late token (wrong id): chat streaming, last message a1 streaming "It has shipped."
token after stop: chat streaming, last message a4 streaming "Sure, this must not appear"
messages: 8 | a4: {"id":"a4","role":"assistant","content":"Sure, this must not appear","status":"streaming"}

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 a token means later without touching the callers.

  • ▸

    patch maps 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. 1

    The problem this solves: With several useState variables (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 missed setError("") 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. 2

    *Recall from Topic 4.1: every message has an id and a status (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, the status (idle, waiting, streaming, done or error), 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. 3

    The five actions. send (the user pressed send): add the user's message and an empty assistant placeholder, go to waiting. token (a piece arrived): append to the placeholder, go to streaming. done (the stream ended normally): mark the message done, go to done. error (a failure): keep any text, mark the message error, store a message for the user. stop (the user pressed stop): keep the text, mark it stopped. Every action that updates an answer carries the message id it is about.

  4. 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. 5

    Making impossible states impossible. The reducer *refuses* actions that don't fit. send while the chat is waiting or streaming is ignored, so two answers can't stream at once. token, done, error or stop whose id isn't the current answer is ignored, so a late piece from an old answer can't write into a new one. token after stop is ignored, so a stopped answer stays stopped. Refusing is done by return state; (the same object), which also tells React nothing changed.

  6. 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, no Date.now(), no Math.random(), no localStorage. 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. 7

    Side effects live outside. The fetch, the reading loop and the AbortController stay in your event handler; the handler only *reports* what happens by calling dispatch(action). One warning: the reducer ignoring a send does 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. 8

    Edge cases the reducer owns. A stream that ends with no text: done marks the message empty so the screen can say "(no answer)". An error after some text: the partial text stays, the status says error. Whether a chat is busy is derived from status, never stored as a second variable.

Explain it without notes

01

What is a reducer, and why is it a good fit for chat state?

02

Why do token, done, error and stop carry an id?

03

What is an optimistic update, and where does Mitra use one?

04

Why can't a reducer call fetch?

Practice

01

Add a clear action to the reducer that empties the conversation. It must be ignored while the chat is busy.

02

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 refused send or a stale token is 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.