Just-Use-Convex Docs
AI Agent

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 + useAgentChat hooks run in a separate createRoot, 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 TypeDescriptionRendering
textStreamed text contentMarkdown renderer
reasoningThinking/reasoning tokensCollapsible details block
tool-invocationTool call with args + resultTool-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 snippets
  • write_todos / update_todo -- renders a step-by-step plan with status indicators
  • task -- 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 AgentsProvider accepts token as 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)
  • useSyncExternalStore accepts 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 useAgentInstance

Return to Overview or see Configuration.