n4nAI

Handling loading and error states in useChat

Complete guide to handling loading and error states in Vercel AI SDK's useChat hook with practical patterns for production chat UIs.

n4n Team3 min read706 words

Audio narration

Coming soon — every post will get a voice note here.

The useChat hook from Vercel AI SDK handles the heavy lifting of streaming responses, but the documentation treats loading and error states as an afterthought. In production, these states determine whether your chat interface feels responsive or broken. This tutorial walks through the complete state machine, shows patterns that survive real traffic, and explains where the SDK falls short.

Step 1: Understand the state machine

useChat exposes three primary status values that map to distinct UI phases:

type ChatStatus = 'submitted' | 'streaming' | 'ready' | 'error'
  • submitted — Request sent, waiting for first token
  • streaming — Tokens arriving incrementally
  • ready — Stream complete, idle
  • error — Terminal failure state

The hook also provides isLoading: boolean (true during submitted + streaming) and error: Error | undefined. Most tutorials stop here. The gap: isLoading conflates “waiting for first byte” with “receiving tokens,” but these need different UX.

// app/chat/page.tsx
'use client'

import { useChat } from 'ai/react'

export default function Chat() {
  const { messages, input, handleInputChange, handleSubmit, status, error, isLoading } = useChat({
    api: '/api/chat',
  })

  return (
    <div className="flex flex-col h-screen">
      <Messages messages={messages} status={status} />
      <Composer
        input={input}
        onChange={handleInputChange}
        onSubmit={handleSubmit}
        disabled={isLoading}
        status={status}
        error={error}
      />
    </div>
  )
}

Step 2: Distinguish submitted from streaming

The submitted phase is where users perceive latency. A blank composer with a disabled send button feels broken. Show a “thinking” indicator immediately after submit, before the first token arrives.

// components/Composer.tsx
'use client'

import { useState, FormEvent } from 'react'

interface ComposerProps {
  input: string
  onChange: (e: React.ChangeEvent<HTMLTextAreaElement>) => void
  onSubmit: (e: FormEvent<HTMLFormElement>) => void
  disabled: boolean
  status: 'submitted' | 'streaming' | 'ready' | 'error'
  error: Error | undefined
}

export function Composer({ input, onChange, onSubmit, disabled, status, error }: ComposerProps) {
  const [isComposing, setIsComposing] = useState(false)

  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    if (!input.trim() || disabled) return
    setIsComposing(true)
    onSubmit(e)
  }

  return (
    <form onSubmit={handleSubmit} className="border-t p-4">
      <div className="flex gap-2">
        <textarea
          value={input}
          onChange={onChange}
          disabled={disabled}
          placeholder={status === 'submitted' ? 'Sending...' : status === 'streaming' ? 'Receiving...' : 'Type a message...'}
          className="flex-1 min-h-[60px] max-h-[200px] resize-none px-3 py-2 border rounded-lg"
          rows={1}
        />
        <button
          type="submit"
          disabled={disabled || !input.trim()}
          className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50 disabled:cursor-not-allowed"
        >
          {status === 'submitted' && <Spinner className="w-4 h-4" />}
          {status !== 'submitted' && 'Send'}
        </button>
      </div>
      
      {status === 'submitted' && (
        <p className="mt-2 text-sm text-gray-500 animate-pulse">Waiting for response...</p>
      )}
      
      {error && (
        <ErrorBanner error={error} onRetry={() => { setIsComposing(false); onSubmit(new Event('submit') as any) }} />
      )}
    </form>
  )
}

function Spinner({ className }: { className?: string }) {
  return (
    <svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
      <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
      <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
      <animateTransform attributeName="transform" type="rotate" from="0 12 12" to="360 12 12" dur="1s" repeatCount="indefinite" />
    </svg>
  )
}

Verify: Submit a message. You should see “Waiting for response…” immediately, before any tokens appear. The send button shows a spinner during submitted, then re-enables during streaming if you allow follow-up messages.

Step 3: Render streaming tokens incrementally

The messages array contains the assistant message with content: '' during submitted, then populates incrementally during streaming. Render it as a single streaming element, not a completed message.

// components/Messages.tsx
'use client'

import { Message } from 'ai/react'

interface MessagesProps {
  messages: Message[]
  status: 'submitted' | 'streaming' | 'ready' | 'error'
}

export function Messages({ messages, status }: MessagesProps) {
  return (
    <div className="flex-1 overflow-y-auto p-4 space-y-4">
      {messages.map((message, i) => (
        <MessageBubble key={message.id} message={message} isLast={i === messages.length - 1} status={status} />
      ))}
      {status === 'streaming' && <ScrollAnchor />}
    </div>
  )
}

function MessageBubble({ message, isLast, status }: { message: Message; isLast: boolean; status: string }) {
  const isStreaming = isLast && (status === 'streaming' || status === 'submitted')
  
  return (
    <div className={`flex gap-3 ${message.role === 'assistant' ? '' : 'flex-row-reverse'}`}>
      <div className={`max-w-[70%] ${message.role === 'assistant' ? 'bg-gray-100' : 'bg-blue-600 text-white'} rounded-2xl px-4 py-2`}>
        {message.role === 'assistant' ? (
          <>
            <Prose content={message.content} isStreaming={isStreaming} />
            {isStreaming && <CursorBlink />}
          </>
        ) : (
          <p className="whitespace-pre-wrap">{message.content}</p>
        )}
      </div>
    </div>
  )
}

function Prose({ content, isStreaming }: { content: string; isStreaming: boolean }) {
  // Simple markdown-ish rendering; replace with react-markdown in production
  return (
    <div className="prose prose-sm max-w-none">
      {content.split('\n').map((line, i) => (
        <p key={i} className={isStreaming && i === content.split('\n').length - 1 ? 'min-h-[1.5em]' : ''}>
          {line || <br />}
        </p>
      ))}
    </div>
  )
}

function CursorBlink() {
  return <span className="inline-block w-1 h-4 bg-gray-400 animate-pulse ml-0.5 align-bottom" />
}

function ScrollAnchor() {
  const ref = useRef<HTMLDivElement>(null)
  useEffect(() => { ref.current?.scrollIntoView({ behavior: 'smooth' }) }, [])
  return <div ref={ref} />
}

Verify: Send a long prompt. Tokens should appear character-by-character with a blinking cursor on the last line. No layout shift when streaming completes.

Step 4: Handle errors with context

The error object contains the raw fetch error. In practice, you need to distinguish:

  • Network failures (retryable)
  • Rate limits (retryable with backoff)
  • Auth failures (require re-login)
  • Upstream model errors (may need different model)
// components/ErrorBanner.tsx
'use client'

import { useCallback } from 'react'

interface ErrorBannerProps {
  error: Error
  onRetry: () => void
  onDismiss: () => void
}

export function ErrorBanner({ error, onRetry, onDismiss }: ErrorBannerProps) {
  const errorType = classifyError(error)
  
  return (
    <div className="mt-3 p-3 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
      <svg className="w-5 h-5 text-red-500 mt-0.5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
        <path fillRule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clipRule="evenodd" />
      </svg>
      <div className="flex-1 min-w-0">
        <p className="text-sm font-medium text-red-800">{errorType.title}</p>
        <p className="text-sm text-red-600 mt-1">{errorType.message}</p>
      </div>
      <div className="flex gap-2 flex-shrink-0">
        {errorType.retryable && (
          <button onClick={onRetry} className="text-sm text-red-700 hover:text-red-900 underline">
            Retry
          </button>
        )}
        <button onClick={onDismiss} className="text-sm text-red-500 hover:text-red-700">
          Dismiss
        </button>
      </div>
    </div>
  )
}

function classifyError(error: Error): { title: string; message: string; retryable: boolean } {
  // Network errors
  if (error instanceof TypeError && error.message.includes('fetch')) {
    return { title: 'Connection failed', message: 'Check your network and try again.', retryable: true }
  }
  
  // HTTP errors from the API route
  if ('status' in error && typeof error.status === 'number') {
    switch (error.status) {
      case 401:
        return { title: 'Session expired', message: 'Please sign in again.', retryable: false }
      case 429:
        return { title: 'Rate limited', message: 'Too many requests. Waiting a moment may help.', retryable: true }
      case 500:
      case 502:
      case 503:
        return { title: 'Server error', message: 'Our servers are having issues. Retry in a few seconds.', retryable: true }
      default:
        return { title: `Error ${error.status}`, message: error.message, retryable: error.status >= 500 }
    }
  }
  
  // AbortError from user navigation
  if (error.name === 'AbortError') {
    return { title: 'Request cancelled', message: 'The request was cancelled.', retryable: false }
  }
  
  return { title: 'Unknown error', message: error.message, retryable: true }
}

Verify: Disconnect network and send a message. Banner shows “Connection failed” with retry button. Click retry after reconnecting — message sends again.

Step 5: Implement exponential backoff retry

Naive retry loops hammer degraded endpoints. Implement client-side backoff with jitter, and expose attempt count so the UI can show progress.

// hooks/useChatWithRetry.ts
'use client'

import { useChat, UseChatOptions } from 'ai/react'
import { useCallback, useRef, useState } from 'react'

interface UseChatWithRetryOptions extends UseChatOptions {
  maxRetries?: number
  baseDelayMs?: number
  maxDelayMs?: number
}

export function useChatWithRetry(options: UseChatWithRetryOptions = {}) {
  const { maxRetries = 3, baseDelayMs = 1000, maxDelayMs = 10000, ...chatOptions } = options
  const [retryState, setRetryState] = useState<{ attempt: number; maxAttempts: number } | null>(null)
  const abortControllerRef = useRef<AbortController | null>(null)
  const retryTimeoutRef = useRef<NodeJS.Timeout | null>(null)

  const chat = useChat({
    ...chatOptions,
    onError: handleError,
    onFinish: handleFinish,
  })

  function handleError(error: Error) {
    chatOptions.onError?.(error)
    
    if (retryState && retryState.attempt >= maxRetries) {
      setRetryState(null)
      return
    }

    if (!isRetryableError(error)) {
      return
    }

    const attempt = (retryState?.attempt ?? 0) + 1
    const delay = Math.min(baseDelayMs * Math.pow(2, attempt - 1) + Math.random() * 1000, maxDelayMs)
    
    setRetryState({ attempt, maxAttempts: maxRetries })
    
    retryTimeoutRef.current = setTimeout(() => {
      setRetryState(prev => prev ? { ...prev, attempt: prev.attempt + 1 } : null)
      chat.reload()
    }, delay)
  }

  function handleFinish() {
    if (retryTimeoutRef.current) {
      clearTimeout(retryTimeoutRef.current)
    }
    setRetryState(null)
  }

  const cancelRetry = useCallback(() => {
    if (retryTimeoutRef.current) {
      clearTimeout(retryTimeoutRef.current)
    }
    setRetryState(null)
  }, [])

  return {
    ...chat,
    retryState,
    cancelRetry,
  }
}

function isRetryableError(error: Error): boolean {
  if (error instanceof TypeError && error.message.includes('fetch')) return true
  if ('status' in error && typeof error.status === 'number') {
    return error.status === 429 || error.status >= 500
  }
  return false
}

Update the composer to show retry progress:

// components/Composer.tsx (add to existing)
{retryState && (
  <div className="mt-2 text-sm text-amber-700 bg-amber-50 p-2 rounded flex items-center gap-2">
    <Spinner className="w-4 h-4" />
    <span>Retrying... attempt {retryState.attempt} of {retryState.maxAttempts}</span>
    <button onClick={cancelRetry} className="ml-2 underline">Cancel</button>
  </div>
)}

Verify: Simulate a 500 error from your API route. Watch the retry count increment with increasing delays. After max retries, the error banner appears with a manual retry button.

Step 6: Handle abort and navigation

Users navigate away mid-stream. The SDK aborts the fetch, throwing an AbortError that lands in onError. Don’t treat this as a user-facing error.

// app/chat/page.tsx (update useChat call)
const { messages, input, handleInputChange, handleSubmit, status, error, isLoading, reload, stop } = useChatWithRetry({
  api: '/api/chat',
  onError: (error) => {
    if (error.name === 'AbortError') return // Ignore navigation aborts
    // Log to error tracking service
    console.error('Chat error:', error)
  },
  onFinish: (message) => {
    // Persist to localStorage or analytics
  },
})

Add a stop button during streaming:

// components/Composer.tsx (add to button group)
{status === 'streaming' && (
  <button
    type="button"
    onClick={stop}
    className="px-4 py-2 bg-gray-100 text-gray-700 rounded-lg hover:bg-gray-200"
  >
    Stop
  </button>
)}

Verify: Start a long response, click “Stop” — streaming halts, composer re-enables. Navigate to another page mid-stream — no error banner appears.

Step 7: Persist and restore state

Refresh mid-conversation loses everything. Persist messages to localStorage and hydrate on mount.

// hooks/usePersistedChat.ts
'use client'

import { useChatWithRetry } from './useChatWithRetry'
import { useEffect, useState } from 'react'
import { Message } from 'ai/react'

const STORAGE_KEY = 'chat:messages'

export function usePersistedChat(options: Parameters<typeof useChatWithRetry>[0] = {}) {
  const [hydrated, setHydrated] = useState(false)
  const chat = useChatWithRetry({
    ...options,
    initialMessages: [],
  })

  // Hydrate on mount
  useEffect(() => {
    try {
      const stored = localStorage.getItem(STORAGE_KEY)
      if (stored) {
        const messages = JSON.parse(stored) as Message[]
        chat.setMessages(messages)
      }
    } catch {
      // Corrupted storage — ignore
    } finally {
      setHydrated(true)
    }
  }, [chat])

  // Persist on change
  useEffect(() => {
    if (!hydrated) return
    if (chat.messages.length === 0) {
      localStorage.removeItem(STORAGE_KEY)
    } else {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(chat.messages))
    }
  }, [chat.messages, hydrated])

  return { ...chat, hydrated }
}

Verify: Send a few messages, refresh the page. Conversation history restores. Clear localStorage in DevTools — chat starts empty.

Step 8: Optimize re-renders during streaming

useChat triggers a render per token. For long responses, this creates jank. Memoize message components and use a virtualized list.

// components/Messages.tsx (optimized)
'use client'

import { Message } from 'ai/react'
import { memo, useMemo, useRef, useEffect } from 'react'
import { FixedSizeList as List } from 'react-window'

interface MessagesProps {
  messages: Message[]
  status: 'submitted' | 'streaming' | 'ready' | 'error'
}

export const Messages = memo(function Messages({ messages, status }: MessagesProps) {
  const listRef = useRef<List>(null)
  const isAtBottomRef = useRef(true)

  const itemData = useMemo(() => ({ messages, status }), [messages, status])

  const handleScroll = ({ scrollTop, scrollHeight, clientHeight }: any) => {
    isAtBottomRef.current = scrollTop + clientHeight >= scrollHeight - 50
  }

  const Item = ({ index, style }: { index: number; style: React.CSSProperties }) => (
    <div style={style}>
      <MessageBubble message={messages[index]} isLast={index === messages.length - 1} status={status} />
    </div>
  )

  useEffect(() => {
    if (isAtBottomRef.current && listRef.current) {
      listRef.current.scrollToItem(messages.length - 1, 'auto')
    }
  }, [messages.length, status])

  return (
    <div className="flex-1 overflow-hidden">
      <List
        ref={listRef}
        height={600}
        itemCount={messages.length}
        itemSize={120}
        itemData={itemData}
        onScroll={handleScroll}
        width="100%"
      >
        {Item}
      </List>
      {status === 'streaming' && <div style={{ height: 20 }} />}
    </div>
  )
})

Verify: Stream a 2000-token response. CPU stays low, no frame drops. Scroll up during streaming — auto-scroll pauses. Scroll to bottom — auto-scroll resumes.

Step 9: Server-side error shaping

Client error handling only works if the API route returns structured errors. Don’t let raw provider errors leak.

// app/api/chat/route.ts
import { streamText } from 'ai'
import { openai } from '@ai-sdk/openai'

export async function POST(req: Request) {
  try {
    const { messages } = await req.json()
    
    const result = await streamText({
      model: openai('gpt-4o'),
      messages,
      maxTokens: 2000,
      temperature: 0.7,
    })
    
    return result.toDataStreamResponse()
  } catch (error: any) {
    // Normalize provider errors
    if (error.statusCode === 429) {
      return new Response(JSON.stringify({ error: 'Rate limited' }), {
        status: 429,
        headers: { 'Content-Type': 'application/json', 'Retry-After': '60' },
      })
    }
    
    if (error.statusCode >= 500) {
      return new Response(JSON.stringify({ error: 'Model unavailable' }), {
        status: 503,
        headers: { 'Content-Type': 'application/json' },
      })
    }
    
    return new Response(JSON.stringify({ error: 'Invalid request' }), {
      status: 400,
      headers: { 'Content-Type': 'application/json' },
    })
  }
}

If you’re routing through a gateway that normalizes provider errors (like n4n.ai does with its automatic fallback and unified error format), the client sees consistent status codes regardless of which upstream model serves the request.

Verify: Trigger each error path. Network tab shows structured JSON with appropriate status codes. Client classifyError maps them correctly.

Step 10: Test the complete flow

Write an integration test that exercises the full state machine.

// __tests__/chat-flow.test.tsx
import { render, screen, waitFor, act } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import { Chat } from '@/app/chat/page'
import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

const server = setupServer(
  http.post('/api/chat', async ({ request }) => {
    const { messages } = await request.json()
    const lastMessage = messages[messages.length - 1]?.content ?? ''
    
    if (lastMessage.includes('trigger-500')) {
      return HttpResponse.json({ error: 'Model unavailable' }, { status: 503 })
    }
    
    if (lastMessage.includes('trigger-429')) {
      return HttpResponse.json({ error: 'Rate limited' }, { status: 429, headers: { 'Retry-After': '1' } })
    }
    
    // Stream a response
    const stream = new ReadableStream({
      async start(controller) {
        const encoder = new TextEncoder()
        for (const chunk of ['Hello', ', ', 'world', '!']) {
          controller.enqueue(encoder.encode(`data: ${JSON.stringify({ content: chunk })}\n\n`))
          await new Promise(r => setTimeout(r, 50))
        }
        controller.enqueue(encoder.encode('data: [DONE]\n\n'))
        controller.close()
      }
    })
    
    return new HttpResponse(stream, {
      headers: { 'Content-Type': 'text/plain; charset=utf-8' },
    })
  })
)

beforeAll(() => server.listen())
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

test('complete chat flow with retry', async () => {
  const user = userEvent.setup()
  render(<Chat />)
  
  // Initial state
  expect(screen.getByPlaceholderText('Type a message...')).toBeInTheDocument()
  
  // Send message
  await user.type(screen.getByRole('textbox'), 'Hello')
  await user.click(screen.getByRole('button', { name: /send/i }))
  
  // Submitted state
  expect(screen.getByText('Waiting for response...')).toBeInTheDocument()
  expect(screen.getByRole('button', { name: /send/i })).toBeDisabled()
  
  // Streaming state
  await waitFor(() => expect(screen.getByText('Hello, world!')).toBeInTheDocument())
  expect(screen.getByRole('button', { name: /stop/i })).toBeInTheDocument()
  
  // Ready state
  await waitFor(() => expect(screen.queryByRole('button', { name: /stop/i })).not.toBeInTheDocument())
  expect(screen.getByRole('button', { name: /send/i })).not.toBeDisabled()
  
  // Error flow
  await user.type(screen.getByRole('textbox'), 'trigger-500')
  await user.click(screen.getByRole('button', { name: /send/i }))
  
  await waitFor(() => expect(screen.getByText('Server error')).toBeInTheDocument())
  expect(screen.getByRole('button', { name: /retry/i })).toBeInTheDocument()
  
  // Retry succeeds
  await user.click(screen.getByRole('button', { name: /retry/i }))
  await waitFor(() => expect(screen.getByText('Hello, world!')).toBeInTheDocument())
})

Verify: Run npm test. All assertions pass. The test covers submitted → streaming → ready → error → retry → ready.


What the SDK doesn’t solve

useChat gives you primitives. You still need to build:

  • Message persistence across sessions (localStorage, IndexedDB, or backend)
  • Optimistic updates for instant perceived latency
  • Token usage display during streaming
  • Branch/regenerate UX for alternative completions
  • Attachment handling (images, files) — the hook only manages text
  • Multi-turn tool calling state visualization

The patterns above cover the 80% case. The remaining 20% is where product differentiation lives.

Tagsusechaterror-handlingloading-stateschat-ui

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All vercel ai sdk streaming chat ui (usechat) posts →