Work

/

About
Play
Blog
Contact
Home/Blog/Engineering
Engineering
•
Updated Oct 9, 2026
•
4 min read

React AI Chat: Streaming State, Cancellation and Safe Actions

Diagram of chat state, a streamed response and a separate action boundary
MS
Muhammad Saad

Shopify engineer. I build storefronts, two published Shopify apps, and the infrastructure behind a 70,000+ product store, and I write here about what that work teaches me.

Follow on LinkedInSee my workBook a call
Found this useful? Share it.

Keep reading

Building an Embeddable React Widget That Works on Any Site
Engineering
•
4 min read

Building an Embeddable React Widget That Works on Any Site

Build an embeddable React widget with a script-tag loader, isolated styles, lazy-loaded UI and explicit host-page communication and cleanup.

Jun 24, 2026
Custom React hooks diagram showing reusable subscriptions and cleanup
Engineering
•
4 min read

Custom React Hooks That Survive SSR and Cleanup

Build React media-query hooks that survive server rendering, then test subscription cleanup, changing inputs and delayed widgets before shipping.

Jun 16, 2026
React performance workflow: reproduce, measure and verify a catalog interaction
Engineering
•
5 min read

React Performance: Diagnose Slow Catalog Interactions

Find whether a slow React catalog interaction comes from requests, rendering or layout, then choose a focused fix and verify it with a repeatable trace.

Jan 10, 2026
Back to the blog

© 2026 Muhammad Saad • Colophon

Connect with me on LinkedIn

Elsewhere

  • Github
  • Testimonials
  • CV
  • LinkedIn

Contact

  • Contact details
  • Book a call
  • Email
  • Privacy policy
  • Editorial policy
On this page
  1. 1. Define the example before copying the code
  2. 2. Give one response a clear state contract
  3. 3. Read plain text without breaking character boundaries
  4. 4. Wire cancellation before enabling retries
  5. 5. Keep store actions outside the reply renderer

A React AI chat interface needs to know which request owns each update. Otherwise a delayed chunk from an old response can overwrite a newer answer, and a cancelled request can leave the screen looking complete. I separate the response state, the transport and any action that can change store data.

Quick answer

Give each request an ID, ignore updates for an inactive request and keep stopped or failed partial replies distinct from completed replies. Decode a stream according to its actual protocol: the example here accepts UTF-8 plain text only. Keep authorization and store mutations outside the text-rendering path.

1. Define the example before copying the code

My Dahlia AI case study documents the product problem: a storefront concierge with an operator workflow and human handoff. Its installed widget is vanilla JavaScript. The React modules below are teaching examples, not a claim about Dahlia's production implementation.

The examples assume a same-origin endpoint that returns text/plain; charset=utf-8. They do not parse server-sent events, newline-delimited JSON or a model provider's event protocol. If your backend emits one of those formats, implement its parser before sending text to the reducer.

Transport chunks are also not necessarily words or model tokens. A network read can contain several pieces of text or part of a multibyte character. Naming the action CHUNK helps preserve that distinction when debugging the interface.

2. Give one response a clear state contract

React's useReducer reference describes a pure function that computes the next state from the previous state and an action. I would use that boundary for response status and content, leaving requests and abort controllers outside it.

This module manages the active response only. A surrounding conversation component owns the submitted user messages and completed replies. A new START intentionally replaces the active response, so archive any reply you want to retain before starting another.

responseReducer.js — a request-scoped response
export const initialResponse = {
  requestId: null,
  status: 'idle',
  reply: '',
  error: null,
};

export function responseReducer(state, action) {
  if (action.type === 'START') {
    return {
      requestId: action.id,
      status: 'streaming',
      reply: '',
      error: null,
    };
  }

  if (action.id !== state.requestId || state.status !== 'streaming') {
    return state;
  }

  switch (action.type) {
    case 'CHUNK':
      return { ...state, reply: state.reply + action.text };
    case 'DONE':
      return { ...state, status: 'complete' };
    case 'STOP':
      return { ...state, status: 'stopped' };
    case 'FAIL':
      return { ...state, status: 'error', error: action.message };
    default:
      return state;
  }
}

Use it in a component with useReducer(responseReducer, initialResponse). Every asynchronous callback must carry the ID captured when its request began. Reading a mutable “latest ID” inside an old callback would wrongly grant that callback ownership of the new response.

The status guard also rejects late chunks after completion, cancellation or failure. Preserve the partial text for a stopped response, but label it as incomplete. A partial answer should not visually become a confirmed result just because its loading indicator disappeared.

3. Read plain text without breaking character boundaries

The following browser module accepts the request's AbortSignal and a callback for decoded text. Its endpoint must implement the plain-text contract above. Backend authentication, rate limits and model credentials remain server responsibilities.

streamTextReply.js — UTF-8 plain-text transport
export async function streamTextReply({ messages, signal, onText }) {
  const response = await fetch('/api/chat', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      Accept: 'text/plain',
    },
    body: JSON.stringify({ messages }),
    signal,
  });

  const mime = (response.headers.get('content-type') || '')
    .split(';')[0].trim().toLowerCase();

  if (!response.ok || !response.body || mime !== 'text/plain') {
    if (response.body) await response.body.cancel().catch(() => {});
    throw new Error('Expected a successful plain-text chat response');
  }

  const reader = response.body.getReader();
  const decoder = new TextDecoder('utf-8', { fatal: true });
  try {
    while (true) {
      signal?.throwIfAborted();
      const { value, done } = await reader.read();
      if (done) break;
      const text = decoder.decode(value, { stream: true });
      if (text) onText(text);
    }
    signal?.throwIfAborted();
    const tail = decoder.decode();
    if (tail) onText(tail);
  } finally {
    try {
      await reader.cancel();
    } catch {
      // An aborted or failed stream can already be closed.
    }
    reader.releaseLock();
  }
}

MDN's readable stream guide explains response readers and consuming chunks. Keeping a decoder across reads lets it retain partial character bytes; the final decode flushes any remaining input. Fatal decoding makes malformed UTF-8 an error instead of silently accepting damaged text.

Render the accumulated reply as text. Do not insert streamed model output directly as HTML. If you add rich text later, that is a separate rendering and sanitization decision, not something the transport module handles.

4. Wire cancellation before enabling retries

The caller creates a fresh AbortController and request ID for each submission. Dispatch START, then call the transport with that controller's signal. Its callback dispatches CHUNK with the captured ID; successful completion dispatches DONE.

On a Stop action, dispatch STOP for the current ID and abort its controller. On failure, distinguish an intentional abort from an unexpected error before choosing STOP or FAIL. Abort the active request on component unmount, and never reuse an already aborted signal for a retry.

I would keep the controller in a ref and prevent duplicate submissions synchronously at the event boundary. A disabled button is useful feedback, but it is not the whole concurrency policy. If the design allows replacing a request, abort the previous one and preserve any conversation content that must remain visible.

Response behaviors to test before release
ScenarioRequired result
Old request sends a late chunkNew reply stays unchanged
Shopper stops generationPartial reply is marked stopped
Response has a different content typeVisible failure, not raw protocol text
UTF-8 character crosses chunk boundariesCharacter is reconstructed correctly
Stream ends with invalid UTF-8Response fails rather than claiming completion
Network fails after some textPartial reply remains visibly incomplete

The custom hooks guide covers related ownership and cleanup issues. Test these cases using controlled streams before connecting the actual backend. A successful happy-path demo does not exercise request replacement or teardown.

5. Keep store actions outside the reply renderer

An answer recommending a product and a request to change a cart have different consequences. I would render text first and route structured action proposals through a separate application boundary. A model naming an action does not establish that the shopper requested or authorized it.

An allowlist only identifies recognized actions. Each action still needs argument validation, tenant and resource checks, current product eligibility and any required user confirmation. Server-side writes need their own authorization and duplicate-request handling; hiding a button does not enforce those checks.

OWASP's prompt injection prevention guidance discusses validating tool use and separating untrusted model content from privileged operations. Treat retrieved descriptions and conversation text as data. Neither should grant new permissions to a store action.

Start by testing the response modules with text-only fixtures and the table above. Then review the installation boundary in the embeddable widget guide. Add each state-changing action only after its independent validation and authorization path is defined.