Skip to content

Streaming State Machine

advanced20 min read

The Boolean Graveyard

Here's some code that ships to production every day:

const [isLoading, setIsLoading] = useState(false);
const [isStreaming, setIsStreaming] = useState(false);
const [error, setError] = useState<string | null>(null);
const [isConnecting, setIsConnecting] = useState(false);
const [isCancelled, setIsCancelled] = useState(false);

Five booleans. Each one true or false. That's 2^5 = 32 possible combinations. How many of those are valid? Maybe 6. The other 26 are bugs waiting to happen -- isLoading is true but isStreaming is also true and error is set and isCancelled is true. What does the UI show? Nobody knows. The component doesn't know either.

This is the problem that kills AI chat interfaces. Not the streaming protocol, not the API integration -- the state management. And the fix is something computer science figured out decades ago: a finite state machine.

The Mental Model

Mental Model

Think of a traffic light. It's either red, yellow, or green -- never two colors at once, and it can only change in specific ways (green to yellow, never green to red directly). An LLM streaming connection works exactly the same way. It's in one state at a time, and only certain transitions are valid. When you model it this way instead of juggling booleans, impossible states become literally unrepresentable in your code.

Why Booleans Break Streaming UIs

Let's trace a real bug. Your user clicks "Send", and you set isLoading = true. The stream starts, so you set isStreaming = true and isLoading = false. Tokens arrive. Then the user clicks "Stop Generating". You set isCancelled = true and isStreaming = false. But wait -- an error event fires from the aborted connection. Now error gets set too. Your UI shows an error message over the cancelled state. The user sees "Something went wrong" when they intentionally stopped the response.

Here's why this happens:

// The boolean approach — each setter is independent
async function handleSend(message: string) {
  setError(null);
  setIsLoading(true);
  setIsConnecting(true);

  try {
    const stream = await startStream(message);
    setIsConnecting(false);
    setIsStreaming(true);
    setIsLoading(false);

    for await (const chunk of stream) {
      // What if the user cancelled during this loop?
      // isCancelled might be true but isStreaming is also true
      appendToken(chunk);
    }

    setIsStreaming(false);
  } catch (err) {
    // Was this error from cancellation or a real failure?
    // We can't tell — isCancelled might or might not be true
    setError(err.message);
    setIsLoading(false);
    setIsStreaming(false);
    setIsConnecting(false);
  }
}

Every set* call is independent. Nothing prevents you from reaching a contradictory state. And in async code with race conditions, you will reach one.

Quiz
You have three boolean state variables: isLoading, isStreaming, and hasError. How many possible state combinations exist, and how many are actually valid for a streaming UI?

The Six States of Streaming

Every LLM streaming interaction follows this lifecycle:

Valid Transitions

Not every state can reach every other state. This is the whole point of a state machine -- invalid transitions are impossible by design:

FromToTrigger
IdleConnectingUser sends a message
ConnectingStreamingFirst token received
ConnectingErrorConnection fails, timeout
StreamingCompleteStream ends normally
StreamingCancellingUser clicks "Stop"
StreamingErrorStream breaks mid-response
CancellingIdleAbort cleanup finished
ErrorConnectingUser clicks "Retry"
CompleteConnectingUser sends next message

Notice what's not in this table. You can't go from Idle to Streaming (you must connect first). You can't go from Error to Streaming (you must retry, which goes through Connecting). You can't go from Cancelling to Error (cancellation is intentional, not an error).

Quiz
A user sends a message (Idle to Connecting), but before the first token arrives, they click Stop. What should the state transition be?

Mapping States to UI

Each state maps to exactly one UI configuration. No ambiguity, no conditional soup:

function getUIForState(state: StreamState) {
  switch (state.status) {
    case 'idle':
      return {
        showInput: true,
        showSpinner: false,
        showStopButton: false,
        showRetryButton: false,
        inputDisabled: false,
        placeholder: 'Type a message...',
      };
    case 'connecting':
      return {
        showInput: true,
        showSpinner: true,
        showStopButton: true,
        showRetryButton: false,
        inputDisabled: true,
        placeholder: 'Connecting...',
      };
    case 'streaming':
      return {
        showInput: true,
        showSpinner: false,
        showStopButton: true,
        showRetryButton: false,
        inputDisabled: true,
        placeholder: 'Generating...',
      };
    case 'cancelling':
      return {
        showInput: true,
        showSpinner: true,
        showStopButton: false,
        showRetryButton: false,
        inputDisabled: true,
        placeholder: 'Stopping...',
      };
    case 'error':
      return {
        showInput: true,
        showSpinner: false,
        showStopButton: false,
        showRetryButton: true,
        inputDisabled: false,
        placeholder: 'Type a message or retry...',
      };
    case 'complete':
      return {
        showInput: true,
        showSpinner: false,
        showStopButton: false,
        showRetryButton: false,
        inputDisabled: false,
        placeholder: 'Type a message...',
      };
  }
}

Compare this to the boolean approach:

// Boolean hell — every render has to untangle combinations
const showSpinner = isLoading || isConnecting;
const showStopButton = isStreaming && !isCancelled;
const showRetryButton = !!error && !isLoading;
const inputDisabled = isLoading || isStreaming || isConnecting;
// What about isStreaming && isCancelled? isLoading && error?
// Every new feature adds more boolean spaghetti.
AspectBoolean FlagsState Machine
Possible states2^n (exponential with each flag)Exactly the states you define
Invalid statesSilently exist — bugs hide hereImpossible to represent
Adding a stateAnother boolean, doubles combinationsOne new case, explicit transitions
UI mappingConditional logic with overlapping checksDirect 1:1 switch statement
Race conditionsFlags can conflict across async opsSingle source of truth per transition
DebuggingLog every boolean, guess which combo is wrongLog one status string, immediately clear
TestingTest 2^n combinations (most impractical)Test only valid transitions

Handling User Actions Per State

Here's where the state machine really shines. Each state defines which user actions are valid:

function canSend(status: StreamStatus): boolean {
  return status === 'idle' || status === 'complete' || status === 'error';
}

function canCancel(status: StreamStatus): boolean {
  return status === 'connecting' || status === 'streaming';
}

function canRetry(status: StreamStatus): boolean {
  return status === 'error';
}

With booleans, you'd check !isLoading && !isStreaming && !isConnecting. Miss one? Bug. Add a new flag? Check every condition. With a state machine, you check one value against an explicit list.

Quiz
A user rapidly double-clicks the Send button. With boolean flags (isLoading = false initially), what happens?

Race Condition Prevention

The nastiest bug in streaming UIs: the user sends message A, then quickly sends message B before A finishes streaming. Now you have two active streams writing to the same message array. Tokens interleave. The response is garbled.

A state machine prevents this structurally:

case 'SEND': {
  // Only allow sending from idle, complete, or error
  if (
    state.status !== 'idle' &&
    state.status !== 'complete' &&
    state.status !== 'error'
  ) {
    return state; // Reject — we're connecting, streaming, or cancelling
  }

  return {
    status: 'connecting',
    messages: [
      ...state.messages,
      { role: 'user', content: action.message },
    ],
    currentResponse: '',
    error: null,
    abortController: new AbortController(),
  };
}

The second send attempt arrives while status is 'connecting' or 'streaming'. The reducer returns the current state unchanged. No second request fires. No interleaving. No race condition.

But what if the user wants to send a new message? They need to cancel first. The state machine enforces the flow: Streaming to Cancelling to Idle, then they can send again.

The AbortController Pattern

Cancellation needs to work at both the state level and the network level:

case 'CANCEL': {
  if (
    state.status !== 'connecting' &&
    state.status !== 'streaming'
  ) {
    return state;
  }

  return {
    ...state,
    status: 'cancelling',
  };
}

case 'CANCEL_COMPLETE': {
  if (state.status !== 'cancelling') {
    return state;
  }

  return {
    ...state,
    status: 'idle',
    abortController: null,
  };
}

The CANCEL action transitions state to cancelling. A separate useEffect watches for this state and calls abort() on the controller -- keeping the side effect out of the reducer. The CANCEL_COMPLETE action fires after cleanup finishes. This two-step process prevents the abort error from being treated as a real error -- by the time the error callback fires, the state is 'cancelling', and the reducer ignores error events in that state:

case 'ERROR': {
  // Ignore errors during cancellation — they're expected
  if (state.status === 'cancelling') {
    return state;
  }

  if (
    state.status !== 'connecting' &&
    state.status !== 'streaming'
  ) {
    return state;
  }

  return {
    ...state,
    status: 'error',
    error: action.error,
    abortController: null,
  };
}

This is the fix for the "error after cancel" bug we described at the beginning. The state machine makes it a non-issue.

Implementation with useReducer

Here's the complete implementation. This is the clean pattern:

Types

type StreamStatus =
  | 'idle'
  | 'connecting'
  | 'streaming'
  | 'cancelling'
  | 'error'
  | 'complete';

type Message = {
  role: 'user' | 'assistant';
  content: string;
};

type StreamState = {
  status: StreamStatus;
  messages: Message[];
  currentResponse: string;
  error: string | null;
  abortController: AbortController | null;
};

type StreamAction =
  | { type: 'SEND'; message: string }
  | { type: 'CONNECTED' }
  | { type: 'TOKEN'; token: string }
  | { type: 'COMPLETE' }
  | { type: 'CANCEL' }
  | { type: 'CANCEL_COMPLETE' }
  | { type: 'ERROR'; error: string }
  | { type: 'RETRY' };

Notice the discriminated union on StreamAction. TypeScript narrows the action type inside each case, so you get full type safety on payload access. No action.message on a TOKEN action -- the compiler catches it.

The Reducer

const initialState: StreamState = {
  status: 'idle',
  messages: [],
  currentResponse: '',
  error: null,
  abortController: null,
};

function streamReducer(
  state: StreamState,
  action: StreamAction
): StreamState {
  switch (action.type) {
    case 'SEND': {
      if (
        state.status !== 'idle' &&
        state.status !== 'complete' &&
        state.status !== 'error'
      ) {
        return state;
      }

      return {
        status: 'connecting',
        messages: [
          ...state.messages,
          { role: 'user', content: action.message },
        ],
        currentResponse: '',
        error: null,
        abortController: new AbortController(),
      };
    }

    case 'CONNECTED': {
      if (state.status !== 'connecting') return state;
      return { ...state, status: 'streaming' };
    }

    case 'TOKEN': {
      if (state.status !== 'streaming') return state;
      return {
        ...state,
        currentResponse: state.currentResponse + action.token,
      };
    }

    case 'COMPLETE': {
      if (state.status !== 'streaming') return state;
      return {
        ...state,
        status: 'complete',
        messages: [
          ...state.messages,
          { role: 'assistant', content: state.currentResponse },
        ],
        currentResponse: '',
        abortController: null,
      };
    }

    case 'CANCEL': {
      if (
        state.status !== 'connecting' &&
        state.status !== 'streaming'
      ) {
        return state;
      }
      return { ...state, status: 'cancelling' };
    }

    case 'CANCEL_COMPLETE': {
      if (state.status !== 'cancelling') return state;
      const partialResponse = state.currentResponse;
      return {
        ...state,
        status: 'idle',
        messages: partialResponse
          ? [
              ...state.messages,
              {
                role: 'assistant',
                content: partialResponse + ' [stopped]',
              },
            ]
          : state.messages,
        currentResponse: '',
        abortController: null,
      };
    }

    case 'ERROR': {
      if (state.status === 'cancelling') return state;
      if (
        state.status !== 'connecting' &&
        state.status !== 'streaming'
      ) {
        return state;
      }
      return {
        ...state,
        status: 'error',
        error: action.error,
        abortController: null,
      };
    }

    case 'RETRY': {
      if (state.status !== 'error') return state;
      const lastUserMessage = [...state.messages]
        .reverse()
        .find((m) => m.role === 'user');
      if (!lastUserMessage) return { ...state, status: 'idle' };

      return {
        ...state,
        status: 'connecting',
        error: null,
        abortController: new AbortController(),
      };
    }
  }
}

The Hook

function useStreamChat() {
  const [state, dispatch] = useReducer(streamReducer, initialState);

  const send = useCallback(
    async (message: string) => {
      dispatch({ type: 'SEND', message });
    },
    []
  );

  const cancel = useCallback(() => {
    dispatch({ type: 'CANCEL' });
  }, []);

  const retry = useCallback(() => {
    dispatch({ type: 'RETRY' });
  }, []);

  useEffect(() => {
    if (state.status !== 'cancelling') return;
    state.abortController?.abort();
  }, [state.status, state.abortController]);

  useEffect(() => {
    if (state.status !== 'connecting') return;

    const controller = state.abortController;
    if (!controller) return;

    let cancelled = false;

    (async () => {
      try {
        const response = await fetch('/api/chat', {
          method: 'POST',
          body: JSON.stringify({
            messages: state.messages,
          }),
          signal: controller.signal,
        });

        if (!response.ok) {
          dispatch({
            type: 'ERROR',
            error: `Server error: ${response.status}`,
          });
          return;
        }

        dispatch({ type: 'CONNECTED' });

        const reader = response.body?.getReader();
        const decoder = new TextDecoder();

        if (!reader) {
          dispatch({
            type: 'ERROR',
            error: 'No response body',
          });
          return;
        }

        while (true) {
          const { done, value } = await reader.read();
          if (done) break;

          const text = decoder.decode(value, { stream: true });
          dispatch({ type: 'TOKEN', token: text });
        }

        dispatch({ type: 'COMPLETE' });
      } catch (err) {
        if (cancelled) return;

        if (err instanceof DOMException && err.name === 'AbortError') {
          dispatch({ type: 'CANCEL_COMPLETE' });
        } else {
          dispatch({
            type: 'ERROR',
            error:
              err instanceof Error
                ? err.message
                : 'Unknown error',
          });
        }
      }
    })();

    return () => {
      cancelled = true;
    };
  }, [state.status, state.abortController, state.messages]);

  return {
    ...state,
    send,
    cancel,
    retry,
    canSend:
      state.status === 'idle' ||
      state.status === 'complete' ||
      state.status === 'error',
    canCancel:
      state.status === 'connecting' ||
      state.status === 'streaming',
    canRetry: state.status === 'error',
  };
}

The useEffect watches for the connecting status. When the reducer transitions to connecting, the effect fires and starts the fetch. This keeps the side effect out of the reducer (reducers must be pure) and out of the event handler (which would bypass the state machine).

Common Trap

Never call abort() inside the reducer. Reducers must be pure -- they run during rendering and React StrictMode double-invokes them to catch side effects. The correct pattern is what the hook above does: the reducer only transitions state to cancelling, and a separate useEffect watches for that state and calls abort() on the controller. This keeps the side effect in the effect layer where it belongs.

Quiz
Why does the useEffect depend on state.status instead of being triggered directly by the send function?

How Vercel AI SDK Handles This

If you're using the Vercel AI SDK, you might wonder why you'd bother with a custom state machine. Here's the thing -- useChat already uses one internally. Understanding what it does helps you extend it:

import { useChat } from 'ai/react';

function Chat() {
  const {
    messages,
    input,
    handleInputChange,
    handleSubmit,
    isLoading,
    error,
    stop,
    reload,
    status,
  } = useChat();

  // status is: 'awaiting_message' | 'in_progress' | 'streaming' | 'error'
  // This is their state machine — fewer states than ours,
  // but the same idea
}

The SDK's status field maps to a simplified version of our model:

SDK StatusOur StateNotes
awaiting_messageidle / completeSDK merges these
in_progressconnectingBefore first token
streamingstreamingTokens arriving
(error prop set)errorSDK uses a separate prop

The SDK doesn't expose a cancelling state -- stop() immediately transitions back to awaiting_message. For many apps this is fine. But if you need to show "Stopping..." UI, preserve partial responses with metadata, or handle complex multi-turn cancellation, you'll want the full state machine.

Anthropic's Multi-Level State

Anthropic's streaming API adds another layer of complexity. A single message can contain multiple content blocks (text, tool use, thinking), and each block has its own lifecycle:

Message Stream:
  message_start          → message-level connecting
  content_block_start    → block-level connecting
  content_block_delta    → block-level streaming (tokens)
  content_block_stop     → block-level complete
  content_block_start    → another block starts
  content_block_delta    → more tokens
  content_block_stop     → block complete
  message_delta          → message-level metadata (stop_reason, usage)
  message_stop           → message-level complete

For most chat UIs, you can flatten this into our six-state model. But if you're building a tool-use agent where the AI calls functions mid-response, you might need nested state machines -- one for the overall message, one for the current content block. That's beyond our scope here, but the pattern is the same: explicit states, valid transitions, one state at a time.

The reason Anthropic's API has block-level events is that tool use requires the full tool input before execution. The content_block_stop event for a tool_use block signals that the complete JSON input is available and you can now call the tool. If you only tracked message-level state, you wouldn't know when a tool call's parameters are fully received versus still streaming. This is why state machines compose -- you nest them at different granularities.

Execution Trace: A Complete Flow

Let's trace what happens when a user sends a message, receives a partial response, and cancels:

Execution Trace
User clicks Send
idle to connecting
dispatch SEND, new AbortController created, user message appended
Effect fires
connecting
useEffect detects status=connecting, begins fetch with abort signal
Server responds 200
connecting to streaming
dispatch CONNECTED, response.body reader created
First chunk arrives
streaming
dispatch TOKEN, currentResponse grows, UI renders partial text
More chunks arrive
streaming
Multiple TOKEN dispatches, text accumulates progressively
User clicks Stop
streaming to cancelling
dispatch CANCEL, state transitions to cancelling, useEffect calls abort(), UI shows Stopping
AbortError caught
cancelling to idle
fetch catch block detects AbortError, dispatches CANCEL_COMPLETE, partial response saved
Ready for input
idle
Input re-enabled, partial response visible with stopped marker

Common Mistakes

What developers doWhat they should do
Calling abort() and immediately setting state to idle without a cancelling transition
The abort fires an error event asynchronously. If you skip to idle, the error handler might set the error state on what the user thinks is a fresh idle state. The cancelling state acts as a guard.
Transition to cancelling first, then to idle after cleanup completes in the catch block
Creating a new AbortController on every render or in the event handler
The controller must be the same instance used by both the fetch signal and the cancel action. Storing it in state ensures the reducer and effect reference the same controller.
Create the AbortController in the reducer when transitioning to connecting, store it in state
Using useEffect cleanup to abort on unmount but not handling the resulting error
When the component unmounts, the effect cleanup fires, which may abort the fetch. The AbortError fires, and if the component is unmounted, dispatching to a stale reducer causes React warnings. The cancelled flag prevents this.
Track a cancelled flag in the effect and skip dispatches after cleanup runs
Treating all errors the same — including AbortError from intentional cancellation
AbortError is not a failure — it's the expected result of the user clicking Stop. Showing an error message for intentional cancellation is a UX bug that confuses users.
Check for AbortError specifically and dispatch CANCEL_COMPLETE instead of ERROR

Key Rules

Key Rules
  1. 1One status value, not multiple booleans — if you have isLoading AND isStreaming, you already have a bug
  2. 2Invalid transitions return current state unchanged — the reducer is the gatekeeper
  3. 3Side effects live in useEffect, never in the reducer — reducers must be pure functions
  4. 4AbortController goes in state, not a ref — the reducer needs access to it for the cancel transition
  5. 5Always distinguish AbortError from real errors — cancellation is user intent, not failure
  6. 6The effect reacts to state transitions, not user actions — dispatch first, fetch second
Quiz
You want to add a 'regenerate' feature that re-runs the last assistant response. Which states should the REGENERATE action be valid from?
Quiz
In React StrictMode, reducers run twice during development. What happens if your reducer calls abortController.abort() inside the CANCEL case?

Where to Go From Here