AI chat apps look simple: a prompt box, a streaming response, maybe citations. Under the hood, session management gets messy fast. How do you persist history across refreshes? How much context do you send to the model? What happens when the network hiccups or the user switches devices?

What "session" actually means

A session isn't just a request/response pair. It covers:

  • Conversation history: user and assistant messages across turns
  • Context window: the subset of history you actually send to the model
  • Metadata: user ID, model version, feature flags, experiments
  • Persistence: how long sessions live, how they resume, where they're stored

Your UI needs to render history efficiently, stream new messages, handle reconnects and errors, and stay keyboard- and screen-reader-friendly.

Core UI pattern: a session chat hook

Centralizing message state, loading, errors, and basic persistence in a hook keeps components clean. Here's a simplified usage pattern:

import { useSessionChat } from "@/app/recipes/session/chat/hooks/use-session-chat" import { PromptInput } from "@/components/ui/composites/prompt-input" import { ResponseViewer } from "@/components/ui/composites/response-viewer" export default function SessionChatPage() { const { messages, isLoading, error, sendMessage, retryLastMessage, } = useSessionChat({ sessionId: "customer-support-session", }) const handleSubmit = (value: string) => { sendMessage({ content: value }) } return ( <div className="flex flex-col h-screen"> <div className="flex-1 overflow-auto p-6 space-y-4"> {messages.map((message) => ( <div key={message.id} className={message.role === "user" ? "text-right" : "text-left"}> {message.role === "assistant" ? ( <ResponseViewer content={message.content} /> ) : ( <div className="inline-block bg-primary-50 rounded-lg px-4 py-2"> {message.content} </div> )} </div> ))} {error && ( <div className="mt-4 p-3 rounded-md bg-red-50 text-sm text-red-800 flex items-center justify-between"> <span>{error.message}</span> <button type="button" onClick={retryLastMessage} className="ml-4 text-xs underline" > Retry </button> </div> )} </div> <div className="border-t p-4"> <PromptInput onSubmit={(value) => handleSubmit(value)} disabled={isLoading} actionButton="send" /> </div> </div> ) } 

The heavy lifting: state management, pending states, basic retries: lives in the hook, not scattered across components.

Where to store session data

Session data can live in three places, each with trade-offs:

  • In-memory (React state): fast, lost on refresh
  • Local storage: persists across reloads, single device only
  • Backend storage: durable and multi-device, requires APIs

The hook stays agnostic about storage. Hydrating from local storage looks like this:

const { messages, sendMessage, hydrateFromHistory } = useSessionChat({ sessionId }) React.useEffect(() => { const saved = window.localStorage.getItem(`chat:${sessionId}`) if (saved) { hydrateFromHistory(JSON.parse(saved)) } }, [sessionId, hydrateFromHistory]) React.useEffect(() => { window.localStorage.setItem(`chat:${sessionId}`, JSON.stringify(messages)) }, [sessionId, messages]) 

Now sessions survive a refresh without any UI changes.

Managing context windows

LLM context windows force you to decide how much history to send each turn. Common strategies:

  • Last N turns: send only the most recent messages (fast, simple)
  • Summarized history: compress older messages into a running summary
  • Segmented sessions: split long conversations into chapters

The UI should show the full history even if the model only sees part of it. A last-N strategy in your sendMessage handler:

function buildModelPayload(messages: ChatMessage[]) { const MAX_TURNS = 12 const visibleHistory = messages.slice(-MAX_TURNS) return visibleHistory.map((m) => ({ role: m.role, content: m.content, })) } 

The UI stays unchanged; only the payload builder shifts.

Handling errors and retries gracefully

Network hiccups and model errors are inevitable. Bad UIs lose the user's message, show generic banners, and offer no retry path. Better pattern:

  • Preserve the user's message in history
  • Show per-message error states
  • Allow "Retry" on that specific message
// inside message rendering {message.role === "assistant" && message.status === "error" && ( <button type="button" onClick={() => retryMessage(message.id)} className="mt-2 text-xs text-red-700 underline" > Retry this response </button> )} 

Accessibility in session UIs

Session management isn't just data: it's how the experience feels. Screen readers should announce new messages progressively. Keyboard users need to jump to the latest message, re-open prompts, and interact with citations and feedback.

Streaming components need proper aria-live regions, focusable elements for citations and controls, and predictable tab order across messages and input.

<ResponseViewer content={streamedContent} format="markdown" showCitations aria-live="polite" aria-atomic={false} /> 

You don't have to become an ARIA expert to get a reasonable default.

Multi-session interfaces

Many apps need multiple concurrent sessions: conversations per project, customer, or case. Pattern:

  • Left sidebar lists sessions
  • Right panel shows active session chat
  • Both wired to the same session hook with different sessionIds
function SessionsLayout({ sessionId }: { sessionId: string }) { const sessions = useUserSessions() // your data source return ( <div className="flex h-screen"> <aside className="w-72 border-r bg-secondary-50"> {/* session list */} {sessions.map((session) => ( <button key={session.id} className="w-full text-left px-4 py-2 hover:bg-secondary-100" onClick={() => router.push(`/sessions/${session.id}`)} > <div className="text-sm font-medium">{session.title}</div> <div className="text-xs text-secondary-600"> Updated {session.updatedAtRelative} </div> </button> ))} </aside> <main className="flex-1"> <SessionChatPage key={sessionId} /> </main> </div> ) } 

The key={sessionId} on SessionChatPage forces a clean remount when switching sessions, avoiding stale state.

Session management checklist

Before shipping, verify:

History

  • Users see past messages after refresh
  • Old messages don't break layout on mobile
  • Long histories still feel fast

Context

  • Clear strategy for what goes to the model
  • Not spamming the entire history on every call

Resilience

  • Network errors preserve the user's message
  • Users can retry specific messages
  • Timeouts and exceptions show clear, actionable errors

Accessibility

  • New messages announced to screen readers
  • Keyboard users navigate messages and actions
  • Focus doesn't jump unexpectedly during streaming

Multi-session

  • Sessions have stable IDs and URLs
  • Switching sessions is fast and predictable
  • Session titles or metadata make sense to users

The last item is a common gotcha: if you derive session titles from the first user message, make sure that title updates when the message edits or deletes, or you'll show stale labels in the sidebar.

This is a shorter version of a guide I wrote at Frontkit Studio. The full post has the complete walkthrough.