By Sagar Shankaran, Founder of CallSphere
Compare and implement state management patterns for AI agent interfaces using React Context for simple state, Zustand for client state, and TanStack Query for server state.
Key takeaways
Agent interfaces manage three distinct categories of state: UI state (sidebar open, selected conversation, theme), client state (message drafts, optimistic messages, local preferences), and server state (conversation history, agent configuration, user profile). Using a single approach for all three creates unnecessary complexity. The modern pattern separates these concerns: React Context for UI state, Zustand for client state, and TanStack Query for server state.
UI state is lightweight, changes infrequently, and affects the visual layout. React Context handles this well without any external library.
flowchart LR
INPUT(["User intent"])
PARSE["Parse plus<br/>classify"]
PLAN["Plan and tool<br/>selection"]
AGENT["Agent loop<br/>LLM plus tools"]
GUARD{"Guardrails<br/>and policy"}
EXEC["Execute and<br/>verify result"]
OBS[("Trace and metrics")]
OUT(["Outcome plus<br/>next action"])
INPUT --> PARSE --> PLAN --> AGENT --> GUARD
GUARD -->|Pass| EXEC --> OUT
GUARD -->|Fail| AGENT
AGENT --> OBS
style AGENT fill:#4f46e5,stroke:#4338ca,color:#fff
style GUARD fill:#f59e0b,stroke:#d97706,color:#1f2937
style OBS fill:#ede9fe,stroke:#7c3aed,color:#1e1b4b
style OUT fill:#059669,stroke:#047857,color:#fff
import {
createContext,
useContext,
useState,
ReactNode,
} from "react";
interface UIState {
sidebarOpen: boolean;
activeConversationId: string | null;
theme: "light" | "dark";
}
interface UIActions {
toggleSidebar: () => void;
setActiveConversation: (id: string | null) => void;
setTheme: (theme: "light" | "dark") => void;
}
const UIContext = createContext<
(UIState & UIActions) | null
>(null);
export function UIProvider({ children }: { children: ReactNode }) {
const [state, setState] = useState<UIState>({
sidebarOpen: true,
activeConversationId: null,
theme: "light",
});
const actions: UIActions = {
toggleSidebar: () =>
setState((s) => ({ ...s, sidebarOpen: !s.sidebarOpen })),
setActiveConversation: (id) =>
setState((s) => ({ ...s, activeConversationId: id })),
setTheme: (theme) =>
setState((s) => ({ ...s, theme })),
};
return (
<UIContext.Provider value={{ ...state, ...actions }}>
{children}
</UIContext.Provider>
);
}
export function useUI() {
const ctx = useContext(UIContext);
if (!ctx) throw new Error("useUI must be inside UIProvider");
return ctx;
}
Context is the right tool here because UI state changes are infrequent (toggling a sidebar, switching conversations) and the provider sits near the top of the tree. The common criticism that Context causes excessive re-renders applies when state changes rapidly, which UI state does not.
Message state changes frequently (every streamed token, every optimistic update) and is complex (multiple messages, status transitions, ordering). Zustand provides a lightweight store that avoids the re-render issues of Context.
import { create } from "zustand";
interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
status: "optimistic" | "streaming" | "complete" | "error";
conversationId: string;
}
interface MessageStore {
messages: Map<string, ChatMessage[]>;
addMessage: (convId: string, msg: ChatMessage) => void;
appendToken: (convId: string, msgId: string, token: string) => void;
updateStatus: (
convId: string,
msgId: string,
status: ChatMessage["status"]
) => void;
getConversationMessages: (convId: string) => ChatMessage[];
}
export const useMessageStore = create<MessageStore>(
(set, get) => ({
messages: new Map(),
addMessage: (convId, msg) =>
set((state) => {
const newMap = new Map(state.messages);
const existing = newMap.get(convId) || [];
newMap.set(convId, [...existing, msg]);
return { messages: newMap };
}),
appendToken: (convId, msgId, token) =>
set((state) => {
const newMap = new Map(state.messages);
const msgs = (newMap.get(convId) || []).map((m) =>
m.id === msgId
? { ...m, content: m.content + token }
: m
);
newMap.set(convId, msgs);
return { messages: newMap };
}),
updateStatus: (convId, msgId, status) =>
set((state) => {
const newMap = new Map(state.messages);
const msgs = (newMap.get(convId) || []).map((m) =>
m.id === msgId ? { ...m, status } : m
);
newMap.set(convId, msgs);
return { messages: newMap };
}),
getConversationMessages: (convId) =>
get().messages.get(convId) || [],
})
);
Zustand shines here because components can subscribe to slices of the store. A component that only reads messages for one conversation will not re-render when messages in another conversation change.
Hear it before you finish reading
Talk to a live CallSphere AI voice agent in your browser — 60 seconds, no signup.
Use Zustand selectors to minimize re-renders. Components that only need to know whether a conversation has unread messages should not re-render when message content changes.
function useConversationMessages(convId: string) {
return useMessageStore(
(state) => state.messages.get(convId) || []
);
}
function useIsStreaming(convId: string) {
return useMessageStore((state) => {
const msgs = state.messages.get(convId) || [];
return msgs.some((m) => m.status === "streaming");
});
}
function useMessageCount(convId: string) {
return useMessageStore(
(state) => (state.messages.get(convId) || []).length
);
}
Each selector creates a subscription that only triggers re-renders when its return value changes. This is far more efficient than subscribing to the entire store.
Server state — conversation history, agent configuration, user profile — lives on the backend and should be fetched, cached, and synchronized. TanStack Query handles this with automatic caching, background refetching, and stale-while-revalidate patterns.
import {
useQuery,
useMutation,
useQueryClient,
} from "@tanstack/react-query";
interface Conversation {
id: string;
title: string;
createdAt: string;
messageCount: number;
}
function useConversations() {
return useQuery<Conversation[]>({
queryKey: ["conversations"],
queryFn: () =>
fetch("/api/conversations").then((r) => r.json()),
staleTime: 60_000,
});
}
function useCreateConversation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (title: string) =>
fetch("/api/conversations", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ title }),
}).then((r) => r.json()),
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["conversations"],
});
},
});
}
The staleTime: 60_000 tells TanStack Query that the conversation list is fresh for 60 seconds. During that window, navigating away and back will show cached data instantly without a loading spinner.
When the user opens a conversation, fetch the history from the server and populate the Zustand store. This bridges server and client state.
Still reading? Stop comparing — try CallSphere live.
CallSphere ships complete AI voice agents per industry — 14 tools for healthcare, 10 agents for real estate, 4 specialists for salons. See how it actually handles a call before you book a demo.
function useLoadConversation(convId: string) {
const addMessage = useMessageStore((s) => s.addMessage);
return useQuery({
queryKey: ["conversation-history", convId],
queryFn: async () => {
const res = await fetch(`/api/conversations/${convId}/messages`);
const messages: ChatMessage[] = await res.json();
messages.forEach((msg) => addMessage(convId, msg));
return messages;
},
staleTime: Infinity, // Only fetch once per session
});
}
Setting staleTime: Infinity ensures the history is fetched once when the conversation opens and not re-fetched on window focus or component remount. New messages are added through the Zustand store directly from the streaming hook.
The decision tree is straightforward. If the state affects layout or visual mode and changes infrequently, use React Context. If the state is client-only, changes frequently, and multiple components need it, use Zustand. If the state comes from the server and needs caching, refetching, and synchronization, use TanStack Query.
You can, but you lose the automatic caching and background refetching of TanStack Query. You would need to manually implement stale-while-revalidate, deduplication of in-flight requests, and cache invalidation. For simple apps with few API calls, an all-Zustand approach works. For production agent interfaces with many endpoints, the combination is worth the added dependency.
Zustand provides a persist middleware that serializes state to localStorage or sessionStorage. Wrap your store creation with persist and specify a storage key. Be selective about what you persist — message content should come from the server on refresh, but user preferences like theme and sidebar state are good candidates for local persistence.
Both components can subscribe to the same Zustand store using different selectors. The chat component subscribes to messages for the active conversation. The analytics panel subscribes to aggregated metrics derived from the same store. Since Zustand stores are global singletons, both components automatically share the same data without prop drilling or context nesting.
#StateManagement #Zustand #TanStackQuery #ReactContext #TypeScript #AgenticAI #LearnAI #AIEngineering

Written by
Sagar Shankaran· Founder, CallSphere
LinkedInSagar Shankaran is the founder of CallSphere, where he builds production AI voice and chat agents deployed across healthcare, hospitality, real estate, and home services. He writes about agentic AI, LLM engineering, and shipping voice agents that handle real calls in production.
See how AI voice agents work for your industry. Live demo available -- no signup required.
AI SDK 5 ships fully typed chat for React, Svelte, Vue, and Angular plus first-class agent loop primitives. Here are the patterns that matter for shipping in 2026.
Mastra.ai is becoming the go-to TypeScript agent framework in 2026. Workflows, RAG, evals, and an honest comparison with Vercel AI SDK 5 for serious teams.
State management is the unglamorous part of chatbots that decides whether they survive scale. The 2026 patterns and where they break.
Convex's reactive queries auto-push every transcript update to every subscribed client. Real working code for actions, mutations, and OpenAI Realtime streaming.
The MCP TypeScript SDK 1.0 makes server development straightforward and type-safe. A walkthrough from scaffold to a deployed Cloud Run service ready for production traffic.
AI SDK 5 ships agent loops, MCP client, and Generative UI v2 — the cleanest TypeScript stack for shipping AI features in 2026 without rewriting your frontend.
© 2026 CallSphere Inc. All rights reserved.
Made within San Francisco
Watch how CallSphere handles real customer calls, schedules appointments, and processes payments — live.
Try Live DemoBook a DemoCalculate Your ROI