AI Engineering
/
AI Application Integration
/

Implement a streaming state reducer

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.

Medium
Hands-on

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.

  1. start returns a fresh streaming state for its run, { runId, status: "streaming", text: "", lastSeq: 0, error: null }, whatever the current state.
  2. Any other action whose run ID differs from state.runId, or any other action while status is not "streaming", returns the same state object. Complete, interrupted, stopped, and failed are therefore sticky until the next start.
  3. stop sets status "stopped", and disconnect sets "interrupted". Text, lastSeq, and error are kept.
  4. An event whose seq is not a safe integer of at least 1 sets status "failed" with error "Invalid event sequence".
  5. An event with seq <= lastSeq was already applied: return the same state object, not an equal copy.
  6. An event with seq !== lastSeq + 1 sets status "interrupted" with error "Missing event", keeping text and lastSeq.
  7. Otherwise apply the event and set lastSeq to its seq: delta appends its text, done sets "complete", and error sets "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.

Open coding workspace

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.

CaseStarting stateActions, in orderrunIdstatustextlastSeqerrorLast action returns the same object?
T1idlestart R1??????
T2idlestart R1; R1 delta 1 "Hello"??????
T3r1HelloR1 delta 1 "Hello"??????
T4r1HelloR1 delta 3 " world"??????
T5r1Hellostop R1??????
T6r1Stoppedstart R2??????
T7r1Stoppedstart R2; R2 delta 1 "Hi"; R1 delta 2 " world"??????
T8idlestart R2; R2 delta 1 "Hi"; R2 done 2??????
T9r2Completedisconnect R2??????
T10idlestart R3; R3 delta 1 "Partial"; R3 error 2 "Upstream timeout"??????
T11idlestart R3; R3 delta 1 "Hi"; R3 delta 0 "x"??????
T12r1HelloR1 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.

ReadWhat arrivesComplete frames parsedLeft 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
2The 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 newlineseq 3 donenothing

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();
}
  1. 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.
  2. Describe the corrected flow from submit to the end of the stream: which controller each run uses, when start is dispatched, how bytes become validated events, what happens at EOF, and what Stop, replacement, and unmount do.
  3. 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

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.

Practice the interview question

Answer it in your own words before you look at the reference answer.