AI Application Integration · 2 to 3 hours
Implement a streaming state reducer
Implement a pure reducer that keeps a streamed answer consistent through stale runs, replays, gaps, and stops, and predict its transitions. Then review a fetch integration for decoding, framing, cancellation, and end-of-stream defects.
3 h
Overview
Implement deterministic client state for a streamed AI response: run identity, sequence numbers, cancellation, and partial failure. The runnable exercise is a pure reducer for React `useReducer` that needs no browser or paid API. The rest of the guide connects it to a fetch stream and an accessible interface, where decoding, framing, and backend reconciliation stay integration responsibilities.
Study time: 2 to 3 hours
Interview format: Live-coding exercise with deterministic transition fixtures, then an integration code review
By the end you can
- Define a streaming event contract with run identity, one-based sequence numbers, and explicit terminal events
- Implement a pure reducer that ignores stale, duplicate, and late actions and marks gaps as interrupted
- Decode bytes and frame events correctly when characters and frames span network reads
- Connect the reducer to fetch with a per-run AbortController and handlers that update only their own run
- Present streaming, stopped, interrupted, failed, and complete states accessibly and render untrusted output safely
Prerequisites
Helpful background: TypeScript and React `useReducer` help for reading the contract and the integration code. The practice problem runs in JavaScript or Python.
This topic covers client and server streaming state, cancellation, reconnects, citations, and accessible UI. Model Serving & Inference covers runtime scheduling, memory, and hardware.
Define an event contract
Each server event carries a run ID and a one-based sequence number. A delta event appends text, done marks a complete answer, and error ends the run with a safe error message. The sequence covers every persisted event for that run, so a client can tell a replay from a gap.
The client starts a run through a local action. It accepts only events that belong to the active run and requires contiguous sequence numbers. An event that was already applied is ignored, and a gap marks the response interrupted rather than silently losing text.
The duplicate rule assumes the payload for a sequence number never changes; two different payloads under one number are a protocol violation for server-side integrity checks to catch.
Local Stop means the client stopped this attempt. It does not assert that the server cancelled every operation. A separate reconciliation step can later update the persisted run record from authoritative server state.
The reducer contract
The reducer receives the current state and one action and returns the next state. It is pure: no clock, no network, and no mutation of its inputs. It never throws: a bad sequence number becomes a failed state, not an exception. When an action changes nothing it returns the same state object, so useReducer can skip the re-render; any change returns a new object with exactly the five fields runId, status, text, lastSeq, and error.
type Status = "idle" | "streaming" | "complete" | "interrupted" | "stopped" | "failed";
type State = {
runId: string | null;
status: Status;
text: string;
lastSeq: number;
error: string | null;
};
// seq is untrusted network data: the reducer validates it before use (rule 4).
type ServerEvent = { runId: string; seq: unknown } & (
| { type: "delta"; text: string }
| { type: "done" }
| { type: "error"; message: string }
);
type Action =
| { type: "start"; runId: string }
| { type: "stop"; runId: string }
| { type: "disconnect"; runId: string }
| { type: "event"; event: ServerEvent };
const initialState: State = {
runId: null, status: "idle", text: "", lastSeq: 0, error: null
};
declare function reduceStream(state: State, action: Action): State;Apply these rules in order; the first one that matches decides the result.
startreturns a fresh streaming state for its run,{ runId, status: "streaming", text: "", lastSeq: 0, error: null }, whatever the current state.- Any other action whose run ID differs from
state.runId, or any other action whilestatusis not"streaming", returns the same state object. Complete, interrupted, stopped, and failed are therefore sticky until the nextstart. stopsets status"stopped", anddisconnectsets"interrupted". Text,lastSeq, and error are kept.- An event whose
seqis not a safe integer of at least 1 sets status"failed"with error"Invalid event sequence". - An event with
seq <= lastSeqwas already applied: return the same state object, not an equal copy. - An event with
seq !== lastSeq + 1sets status"interrupted"with error"Missing event", keeping text andlastSeq. - Otherwise apply the event and set
lastSeqto itsseq:deltaappends its text,donesets"complete", anderrorsets"failed"with the event's message as the error.
A gap ends live accumulation on purpose. Reconnecting needs a separate snapshot or resume operation (the Hard question in this topic), not more events sent into an interrupted state.
The Python version is reduce_stream(state, action) on dictionaries, with run_id and last_seq in place of runId and lastSeq. Everything else, including the status and error strings, is identical. In Python, seq must be an int that is not a bool, from 1 to 2**53 - 1.
Code exercise
Streaming state reducer
Implement reduceStream (Python reduce_stream) to the contract above. Apply the seven rules in order, return the same object when nothing changes, and never mutate the input state or action.
Expected
The linked practice problem writes its starting states as literals (it does not use your initialState), applies actions through your reducer, and compares results with toEqual (Python ==). Its six tests cover the nine transition scenarios from the guide as one table (T1 to T9 below, with the same starting states), applied delta, done, and error events, actions for another run or a finished run returning the same object, a replay (seq <= lastSeq) returning the same object rather than an equal copy (toBe in JavaScript, is in Python), gaps, invalid sequence values such as 0, 1.5, true, and 2**53, and a start that always resets while the inputs stay unmodified.
Limitations
The reducer trusts that a runtime validator already checked event types, field types, string sizes, and the run-ID format; TypeScript types alone do not validate network data. It checks sequence numbers defensively but is not a protocol parser: it does not decode bytes, abort requests, or reconcile with the server. It treats a gap as the end of live accumulation, so reconnecting needs a separate snapshot or resume operation, and its duplicate rule assumes the payload for a sequence number never changes.
Predict the transitions
Predict the outcome
Predict the transitions
Each case is an independent scenario: apply its actions, in order, to its starting state with the seven rules, and predict the final state before you reveal it. The starting states, written as { runId, status, text, lastSeq, error }, are idle = { null, "idle", "", 0, null }, r1Hello = { "R1", "streaming", "Hello", 1, null }, r1Stopped = { "R1", "stopped", "Hello", 1, null }, and r2Complete = { "R2", "complete", "Hi", 2, null }. Shorthand: start R1, stop R1, and disconnect R1 are actions for run R1. R1 delta 1 "Hello" is an event with run ID R1, seq 1, type delta, and text "Hello". R1 done 2 and R1 error 2 "Upstream timeout" are done and error events with seq 2.
| Case | Starting state | Actions, in order | runId | status | text | lastSeq | error | Last action returns the same object? |
|---|---|---|---|---|---|---|---|---|
| T1 | idle | start R1 | ? | ? | ? | ? | ? | ? |
| T2 | idle | start R1; R1 delta 1 "Hello" | ? | ? | ? | ? | ? | ? |
| T3 | r1Hello | R1 delta 1 "Hello" | ? | ? | ? | ? | ? | ? |
| T4 | r1Hello | R1 delta 3 " world" | ? | ? | ? | ? | ? | ? |
| T5 | r1Hello | stop R1 | ? | ? | ? | ? | ? | ? |
| T6 | r1Stopped | start R2 | ? | ? | ? | ? | ? | ? |
| T7 | r1Stopped | start R2; R2 delta 1 "Hi"; R1 delta 2 " world" | ? | ? | ? | ? | ? | ? |
| T8 | idle | start R2; R2 delta 1 "Hi"; R2 done 2 | ? | ? | ? | ? | ? | ? |
| T9 | r2Complete | disconnect R2 | ? | ? | ? | ? | ? | ? |
| T10 | idle | start R3; R3 delta 1 "Partial"; R3 error 2 "Upstream timeout" | ? | ? | ? | ? | ? | ? |
| T11 | idle | start R3; R3 delta 1 "Hi"; R3 delta 0 "x" | ? | ? | ? | ? | ? | ? |
| T12 | r1Hello | R1 delta 3 " world"; start R2 | ? | ? | ? | ? | ? | ? |
Parse bytes and events separately
A fetch response body supplies bytes. Decode them incrementally with TextDecoderStream or one persistent TextDecoder called with { stream: true }. A multibyte character may span two reads, so decoding each read on its own can corrupt text.
Then parse the application framing. For newline-delimited JSON, keep the unfinished line between reads and parse only complete frames. For server-sent events, use an SSE parser that follows its field and event rules. Splitting on whatever boundary a read happens to end at is not enough.
Set maximum event and buffer sizes, validate every parsed object, and reject unsupported protocol versions. A frame that never completes must not grow memory without bound. A heartbeat is transport metadata: it should not append text or create a gap in the persisted content-event sequence.
Trace one response through decoding and framing
Worked example
Suppose a coding-practice platform streams hint text as newline-delimited JSON: one event per line, and every frame ends with a newline. The answer is "Café ok", and the network delivers it in three reads. The character é is two bytes in UTF-8 (0xC3 0xA9), and the first read ends between them.
| Read | What arrives | Complete frames parsed | Left in the line buffer |
|---|---|---|---|
| 1 | {"runId":"R1","seq":1,"type":"delta","text":"Caf and the first byte of é | none | {"runId":"R1","seq":1,"type":"delta","text":"Caf |
| 2 | The second byte of é, "}, a newline, {"runId":"R1","seq":2,"type":"delta","text":" ok"}, a newline, {"runId":"R1", | seq 1 delta "Café", seq 2 delta " ok" | {"runId":"R1", |
| 3 | "seq":3,"type":"done"} and a newline | seq 3 done | nothing |
A streaming decoder holds back the first byte of é after read 1 and emits é once read 2 completes it. The line buffer carries each unfinished frame into the next read, so the reducer receives three whole events and ends complete with "Café ok".
A fresh decoder per read would show "Caf" followed by two U+FFFD replacement characters. Splitting each read on newlines without carrying the remainder would hand JSON.parse the unfinished frame from read 1, which throws.
Connect the reducer to fetch
Create an AbortController for each run. Dispatch start once the backend has accepted the request and assigned its run identity. Pass the signal to fetch, check response.ok and the expected content type, then consume and validate frames.
Dispatch each validated event with its run ID. If the reader reaches the end of the stream while the run is still streaming and no terminal event was observed, dispatch disconnect. Catch an abort separately from other errors so a user's Stop is not presented as an outage.
In React, start the run from a user action or a carefully managed effect. Cleanup should abort the old reader on unmount or replacement. Development-mode effect re-runs can expose duplicate-start bugs, so use server-side request idempotency when a repeated submission would be costly.
Avoid stale closure and race bugs
Use reducer dispatch or functional updates for incremental text. Appending to a text value captured inside a long-lived callback can overwrite newer chunks.
Associate each controller with its run ID. Pressing Stop for R1 must not abort R2 because a shared variable was replaced. The reducer's identity check protects state, but the transport layer must also cancel the right request.
When an old catch or finally handler runs, it should update only its own run. A generic setLoading(false) in an outdated request can hide the loading indicator of the current one.
Review a fetch integration
Diagnose
Review a fetch integration
Suppose a coding-practice platform streams hints. The backend accepts a request, assigns a run ID, and answers with status 200, content type application/x-ndjson, and an x-run-id header. It then streams one event per line in the event format above, and every frame, including the last, ends with a newline. The hint panel shows the reducer's text and status, plus a spinner driven by setLoading. A teammate connected the reducer like this (showBanner displays a page-level error):
let controller: AbortController | null = null;
async function requestHint(
prompt: string,
dispatch: (action: Action) => void,
setLoading: (on: boolean) => void
): Promise<void> {
controller = new AbortController();
setLoading(true);
try {
const response = await fetch("/api/hints", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ prompt }),
signal: controller.signal
});
const runId = response.headers.get("x-run-id") ?? "pending";
dispatch({ type: "start", runId });
const reader = response.body!.getReader();
while (true) {
const { done, value } = await reader.read();
if (done) break;
const chunk = new TextDecoder().decode(value);
for (const line of chunk.split("\n")) {
if (line.trim() !== "") {
dispatch({ type: "event", event: JSON.parse(line) as ServerEvent });
}
}
}
} catch {
showBanner("Hints are unavailable right now.");
} finally {
setLoading(false);
}
}
function stopHint(): void {
controller?.abort();
}- Find at least five defects. For each one, give a concrete sequence of user actions or network behavior that triggers it, and what the learner sees.
- Describe the corrected flow from submit to the end of the stream: which controller each run uses, when
startis dispatched, how bytes become validated events, what happens at EOF, and what Stop, replacement, and unmount do. - Name one test, with a fake reader or a local fake server, that would catch each defect.
Buffer rendering without losing semantics
Updating React state for every small delta can create unnecessary work. Batch deltas over a short interval or an animation frame when needed, then flush pending text before processing done, error, or stop.
Keep event accounting separate from render batching. If several deltas are combined for display, keep the sequence cursor of the last applied event so a replay does not duplicate content.
Cap message size, and measure responsiveness before adding complexity: Markdown parsing and syntax highlighting can cost more than the text-state update itself.
Partial failure and retry behavior
After an interruption, keep the partial answer with a clear label. If the server supports status lookup, query the original run before creating a new generation: it may already have completed and stored the final result.
A new attempt receives a new run identity and its own UI treatment. Do not concatenate it onto the old partial answer. Keep the user's input so they can retry without retyping.
A server-side tool action may have completed even if the answer stream failed. Fetch authoritative operation state or resume the same durable run rather than replaying side effects.
Accessible status and safe rendering
Use a dedicated status region for changes such as generating, interrupted, and complete. Keep the streamed transcript readable without announcing every delta. Preserve keyboard focus, and let the user control scrolling while reading earlier messages.
Render untrusted output with a safe Markdown configuration and explicit link behavior. Keep citation metadata separate from text until references can be resolved, and show unresolved references as pending or unavailable according to the UI contract.
Test the complete flow with a keyboard and a screen reader. An aria-live attribute alone does not establish that the interaction is usable.
Test the complete integration boundary
The reducer tests cover state transitions. Add transport tests that split JSON and UTF-8 across arbitrary reads, combine several frames in one read, omit the terminal event, and send malformed or oversized data.
Add UI tests for rapid submit, stop, and resubmit, unmount during generation, empty output, a partial error, and a terminal event arriving before a render batch flushes. Confirm that the controller aborts the intended request.
Use a local fake streaming server or a controlled reader fixture. You do not need a paid provider to reproduce these integration failures.
Completion checklist
- I can reproduce every transition fixture by hand and with the reducer.
- I can explain why sequence validity is checked before duplicate detection, and why a gap keeps the partial text.
- I can name each defect in the reviewed integration and a test that would catch it.
- I can explain which guarantees still depend on the transport adapter and the backend.
Sources
- MDN: Using readable streams Explains reading a fetch response body incrementally with a reader and cancelling the stream.
- MDN: TextDecoderStream Describes converting a stream of encoded bytes into a stream of strings, the streaming counterpart of TextDecoder.
- MDN: AbortController Aborting fetch requests and body consumption through a signal.
- React: useReducer States that reducers must be pure and must not mutate state, and that React skips re-rendering when the next state is the same object (Object.is).
- W3C: Understanding status messages (WCAG 2.1) Explains communicating status changes to assistive technology without moving focus.
- W3C technique ARIA22: role=status Describes a polite status region for announcing state changes.
The application protocol, identifiers, fixtures, scenarios, and rubric are original synthetic teaching material about a fictional application; they are not a provider API schema. Adapt them to the selected backend and browser support. Study times and readiness thresholds are editorial estimates. Product and library details are as of November 2026.
Related on CodeSnatch
Lessons, problems, and articles that build the skills this question uses.
Code snippet
A React hook with one AbortController per request and a generation counter that drops stale responses.
Question bank
Drills on effect cleanup, stale closures, and aborting an in-flight request on unmount or input change.
Code snippet
Type a reducer over a discriminated union with assertNever, plus a soft fallback for input from the wire.
AI Engineering
Reads termination metadata and stream evidence to tell a short model answer from a broken stream.
Practice the interview question
Answer it in your own words before you look at the reference answer.
