Frontend Integration
Integrating the AI agent with React using isolated roots, useSyncExternalStore, and agents/react
Frontend Integration
The frontend connects to the agent using agents/react and @cloudflare/ai-chat/react, wrapped in a custom provider that creates isolated React root instances per chat to prevent re-render interference.
Architecture
AgentsProvider (context + token)
├── AgentsContext.Provider
│ └── Consumer components (useAgentInstance)
└── Isolated React roots (hidden divs, one per chat)
└── AgentInstanceInner
├── useAgent (WebSocket connection)
└── useAgentChat (message state)Key design decisions:
- Isolated React roots -- each chat's
useAgent+useAgentChathooks run in a separatecreateRoot, so streaming updates don't trigger re-renders in the main tree useSyncExternalStore-- main tree subscribes to instance data via external store pattern, getting controlled updates- Get-or-create pattern -- connections persist across route changes; navigating away and back reuses the existing instance
AgentsProvider
The provider manages chat instances and exposes them via context:
// apps/web/src/providers/agent.tsx
export function AgentsProvider({ children, token }: {
children: ReactNode;
token: string | null | undefined;
}) {
const defaultSettings = useAtomValue(defaultChatSettingsAtom);
const requestInstance = useCallback((chatId: string) => {
createIsolatedInstance(chatId, token, defaultSettings);
}, [token, defaultSettings]);
const subscribe = useCallback((chatId: string, callback: () => void) => {
if (!subscribersStore.has(chatId)) {
subscribersStore.set(chatId, new Set());
}
subscribersStore.get(chatId)!.add(callback);
return () => subscribersStore.get(chatId)?.delete(callback);
}, []);
const getSnapshot = useCallback((chatId: string) => {
return instanceDataStore.get(chatId);
}, []);
return (
<AgentsContext.Provider value={{ requestInstance, subscribe, getSnapshot, defaultSettings }}>
{children}
</AgentsContext.Provider>
);
}Isolated Instances
Each chat runs in a hidden DOM element with its own React root:
function createIsolatedInstance(chatId, token, defaultSettings) {
const existing = isolatedInstances.get(chatId);
if (existing) {
// Re-render existing instance with updated props
existing.root.render(createElement(AgentInstanceInner, { chatId, token, defaultSettings }));
return;
}
const container = document.createElement("div");
container.style.display = "none";
container.dataset.agentInstance = chatId;
document.body.appendChild(container);
const root = createRoot(container);
root.render(createElement(AgentInstanceInner, { chatId, token, defaultSettings }));
isolatedInstances.set(chatId, { root, container });
}AgentInstanceInner
The inner component creates the actual WebSocket connection and chat state:
function AgentInstanceInner({ chatId, token, defaultSettings }) {
const agent = useAgent<ChatSettings>({
agent: "agent-worker",
name: chatId,
host: env.VITE_AGENT_URL,
onStateUpdate: handleStateUpdate,
onMessage: handleMessage,
query: {
token: token ?? null,
model: defaultSettings.model,
reasoningEffort: defaultSettings.reasoningEffort ?? null,
inputModalities: defaultSettings.inputModalities?.join(",") ?? null,
},
});
const chat = useAgentChat({
agent,
resume: true,
onError: handleError,
autoContinueAfterToolResult: true,
});
// Publishes instance data to the external store on mount and updates
useEffect(() => {
updateInstanceData(chatId, { chat, agent, settings, setSettings });
}, [chatId, chat, agent, setSettings]);
return null; // No visual output -- this runs in a hidden div
}useAgentInstance Hook
The primary hook for consuming agent data in the main React tree:
export function useAgentInstance(chatId: string) {
const { requestInstance, subscribe, getSnapshot } = useAgentsContext();
// Request the instance on mount (get-or-create)
useEffect(() => {
requestInstance(chatId);
}, [chatId, requestInstance]);
// Subscribe via useSyncExternalStore for tear-free reads
const subscribeToChat = useCallback(
(callback: () => void) => subscribe(chatId, callback),
[chatId, subscribe]
);
const getSnapshotForChat = useCallback(
() => getSnapshot(chatId),
[chatId, getSnapshot]
);
const data = useSyncExternalStore(subscribeToChat, getSnapshotForChat, getSnapshotForChat);
return {
chat: data?.chat ?? null, // AgentChatInstance (messages, sendMessage, etc.)
agent: data?.agent ?? null, // AgentConnection (setState, WebSocket)
settings: data?.settings ?? {}, // Current ChatSettings
setSettings: data?.setSettings ?? (() => {}),
isReady: !!data?.chat,
};
}Instance Data Shape
type InstanceData = {
chat: AgentChatInstance | null; // useAgentChat return value
agent: AgentConnection | null; // useAgent return value
settings: ChatSettings; // model, reasoningEffort, inputModalities
setSettings: (settingsOrFn: ChatSettings | ((prev: ChatSettings) => ChatSettings)) => void;
};Settings Sync
Settings flow bidirectionally between frontend and agent:
const setSettings = useCallback((settingsOrFn) => {
const next = typeof settingsOrFn === "function" ? settingsOrFn(prev) : settingsOrFn;
// 1. Update local state immediately for responsive UI
settingsRef.current = next;
updateInstanceData(chatId, { chat, agent, settings: next, setSettings });
// 2. Sync to agent via WebSocket
agentRef.current?.setState(next);
}, [chatId]);Server-initiated state updates (from onStateUpdate with source === "server") also flow back to update the local store.
Usage Examples
Basic Chat Page
export default function ChatPage() {
const { chatId } = useParams();
const { chat, settings, setSettings, isReady } = useAgentInstance(chatId);
if (!isReady) return <ChatSkeleton />;
return (
<div className="flex flex-col h-full">
<MessageList messages={chat.messages} />
<ChatInput onSend={(message) => chat.sendMessage(message)} />
</div>
);
}Model Selection
function ModelSelector() {
const { settings, setSettings } = useAgentInstance(chatId);
return (
<select
value={settings.model}
onChange={(e) => setSettings((prev) => ({ ...prev, model: e.target.value }))}
>
{models.map((m) => <option key={m.id} value={m.id}>{m.name}</option>)}
</select>
);
}Reasoning Effort
function ReasoningSelector() {
const { settings, setSettings } = useAgentInstance(chatId);
return (
<select
value={settings.reasoningEffort}
onChange={(e) =>
setSettings((prev) => ({ ...prev, reasoningEffort: e.target.value }))
}
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
);
}Message Parts
Messages from the agent contain typed parts for rendering:
| Part Type | Description | Rendering |
|---|---|---|
text | Streamed text content | Markdown renderer |
reasoning | Thinking/reasoning tokens | Collapsible details block |
tool-invocation | Tool call with args + result | Tool-specific UI components |
Tool Invocation Types
Tool invocations carry a toolName that determines the UI:
ask_user-- renders structured question cards with selectable options. User response is sent back via the tool approval flow.web_search-- displays search results with titles, URLs, and snippetswrite_todos/update_todo-- renders a step-by-step plan with status indicatorstask-- shows sub-agent delegation with streamed progress- Sandbox tools (
list,read,write,edit,exec, etc.) -- rendered inline via the sub-agent's tool results
Tool Approval (ask_user)
The ask_user tool uses the approval mechanism. When the tool fires, the frontend renders a question card. The user's response is sent back as a JSON-encoded AskUserResult:
type AskUserResult = {
answers: Record<string, {
selectedIds: string[];
customText?: string;
}>;
timestamp: number;
};Provider Setup
Wrap chat routes with the provider, passing the auth token:
// src/routes/(protected)/chats/route.tsx
import { AgentsProvider } from "@/providers/agent";
export default function ChatsLayout() {
const { token } = useAuth();
return (
<AgentsProvider token={token}>
<Outlet />
</AgentsProvider>
);
}SSR / Hydration Notes
- The
AgentsProvideracceptstokenas a prop to handle SSR scenarios where auth state must be passed from server to client - Isolated React roots are only created on the client (they require
document.createElement) useSyncExternalStoreaccepts a server snapshot (same as client snapshot) for hydration safety- Default settings come from a Jotai atom (
defaultChatSettingsAtom), which can be hydrated from server state
File Structure
apps/web/src/
├── providers/
│ └── agent.tsx # AgentsProvider, useAgentInstance, isolated roots
├── store/
│ └── models.ts # defaultChatSettingsAtom (Jotai)
├── components/chat/
│ ├── chat-input.tsx # Message input
│ ├── message-item.tsx # Message rendering with parts
│ └── ... # Tool-specific UI components
└── routes/(protected)/chats/
├── route.tsx # Layout with AgentsProvider
└── $chatId.tsx # Chat page using useAgentInstanceReturn to Overview or see Configuration.