Frontend with React¶
This chapter covers building a React + TypeScript frontend that communicates with your FastAPI backend. The frontend handles login, dataset selection, chat-based Q&A, and streaming responses — all through a clean API layer backed by Zustand state management.
Setting Up the Project¶
Scaffold with Vite¶
Install Dependencies¶
Configure Vite Proxy¶
Add an API proxy so the dev server forwards /api requests to your FastAPI backend:
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 5173,
proxy: {
"/api": {
target: "http://localhost:8000",
changeOrigin: true,
},
},
},
});
Why proxy?
The Vite proxy lets the frontend call /api/v1/ask without specifying http://localhost:8000. In production, your reverse proxy (nginx, Caddy) does the same thing. No CORS issues during development.
Project Structure¶
src/
├── main.tsx # React entry point
├── App.tsx # Root component + router setup
├── api/
│ ├── client.ts # Base fetch wrapper with session header
│ └── gaia.ts # Typed Gaia API functions
├── pages/
│ ├── LoginPage.tsx
│ ├── ChatPage.tsx
│ └── DatasetsPage.tsx
├── components/
│ ├── ChatMessage.tsx
│ ├── ChatInput.tsx
│ ├── DatasetSelector.tsx
│ ├── DocumentViewer.tsx
│ └── LoginForm.tsx
├── state/
│ ├── sessionStore.ts
│ ├── chatStore.ts
│ └── datasetStore.ts
├── hooks/
│ ├── useAuth.ts
│ └── useStreaming.ts
└── styles/
└── globals.css
The API Client¶
api/client.ts¶
A thin wrapper around fetch that automatically attaches the session ID header and provides typed helpers.
// src/api/client.ts
const BASE_URL = "/api/v1";
let sessionId: string | null = null;
export function setSessionId(id: string | null) {
sessionId = id;
}
export function getSessionId(): string | null {
return sessionId;
}
async function request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const headers: Record<string, string> = {
"Content-Type": "application/json",
};
if (sessionId) {
headers["X-Session-ID"] = sessionId;
}
const response = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const error = await response.json().catch(() => ({ detail: response.statusText }));
throw new ApiError(response.status, error.detail ?? "Request failed");
}
if (response.status === 204) {
return undefined as T;
}
return response.json();
}
export const api = {
get: <T>(path: string) => request<T>("GET", path),
post: <T>(path: string, body?: unknown) => request<T>("POST", path, body),
put: <T>(path: string, body?: unknown) => request<T>("PUT", path, body),
delete: <T>(path: string) => request<T>("DELETE", path),
};
export class ApiError extends Error {
constructor(
public status: number,
public detail: string,
) {
super(detail);
this.name = "ApiError";
}
}
api/gaia.ts¶
Typed functions that call your backend API. Each function maps to a backend route.
// src/api/gaia.ts
import { api } from "./client";
// ── Types ────────────────────────────────────────────────────────
export interface LoginResponse {
session_id: string;
}
export interface Dataset {
name: string;
status: string | null;
description: string | null;
objectCount: number | null;
}
export interface Document {
docId: string | null;
filename: string | null;
filepath: string | null;
snippet: string | null;
score: number | null;
}
export interface AskResponse {
response: string | null;
query_uid: string | null;
conversation_id: string | null;
documents: Document[] | null;
}
// ── API Functions ────────────────────────────────────────────────
export async function login(apiKey: string): Promise<LoginResponse> {
return api.post<LoginResponse>("/login", { api_key: apiKey });
}
export async function logout(): Promise<void> {
return api.post("/logout");
}
export async function listDatasets(): Promise<{ datasets: Dataset[] }> {
return api.get<{ datasets: Dataset[] }>("/datasets");
}
export async function ask(
datasetNames: string[],
query: string,
conversationId?: string,
): Promise<AskResponse> {
return api.post<AskResponse>("/ask", {
datasetNames,
queryString: query,
conversationId,
});
}
export function askStream(
datasetNames: string[],
query: string,
conversationId?: string,
): { url: string; body: string } {
return {
url: "/api/v1/ask/stream",
body: JSON.stringify({
datasetNames,
queryString: query,
conversationId,
}),
};
}
State Management with Zustand¶
state/sessionStore.ts¶
Manages authentication state. The API key is only held transiently during login — once the session is created, only the session ID is stored.
// src/state/sessionStore.ts
import { create } from "zustand";
import { setSessionId } from "../api/client";
import { login as apiLogin, logout as apiLogout } from "../api/gaia";
interface SessionState {
sessionId: string | null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: (apiKey: string) => Promise<void>;
logout: () => Promise<void>;
restoreSession: (id: string) => void;
}
export const useSessionStore = create<SessionState>((set) => ({
sessionId: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: async (apiKey: string) => {
set({ isLoading: true, error: null });
try {
const { session_id } = await apiLogin(apiKey);
setSessionId(session_id);
localStorage.setItem("sessionId", session_id);
set({ sessionId: session_id, isAuthenticated: true, isLoading: false });
} catch (e: any) {
set({ error: e.message ?? "Login failed", isLoading: false });
}
},
logout: async () => {
try {
await apiLogout();
} catch {
// best effort
}
setSessionId(null);
localStorage.removeItem("sessionId");
set({ sessionId: null, isAuthenticated: false });
},
restoreSession: (id: string) => {
setSessionId(id);
set({ sessionId: id, isAuthenticated: true });
},
}));
state/chatStore.ts¶
Manages chat messages and conversation state.
// src/state/chatStore.ts
import { create } from "zustand";
export interface ChatMessage {
id: string;
role: "user" | "assistant";
content: string;
queryUid?: string;
documents?: any[];
isStreaming?: boolean;
}
interface ChatState {
messages: ChatMessage[];
conversationId: string | null;
isLoading: boolean;
addUserMessage: (content: string) => string;
addAssistantMessage: (id: string, content: string) => void;
updateAssistantMessage: (id: string, content: string) => void;
setAssistantComplete: (id: string, queryUid?: string, documents?: any[]) => void;
setConversationId: (id: string) => void;
setLoading: (loading: boolean) => void;
clearMessages: () => void;
}
let messageCounter = 0;
export const useChatStore = create<ChatState>((set) => ({
messages: [],
conversationId: null,
isLoading: false,
addUserMessage: (content: string) => {
const id = `msg-${++messageCounter}`;
set((state) => ({
messages: [...state.messages, { id, role: "user", content }],
}));
return id;
},
addAssistantMessage: (id: string, content: string) => {
set((state) => ({
messages: [
...state.messages,
{ id, role: "assistant", content, isStreaming: true },
],
}));
},
updateAssistantMessage: (id: string, content: string) => {
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, content } : m,
),
}));
},
setAssistantComplete: (id, queryUid, documents) => {
set((state) => ({
messages: state.messages.map((m) =>
m.id === id ? { ...m, isStreaming: false, queryUid, documents } : m,
),
}));
},
setConversationId: (id: string) => set({ conversationId: id }),
setLoading: (loading: boolean) => set({ isLoading: loading }),
clearMessages: () => set({ messages: [], conversationId: null }),
}));
state/datasetStore.ts¶
Manages available datasets and selection state.
// src/state/datasetStore.ts
import { create } from "zustand";
import { listDatasets, type Dataset } from "../api/gaia";
interface DatasetState {
datasets: Dataset[];
selectedDatasets: string[];
isLoading: boolean;
error: string | null;
fetchDatasets: () => Promise<void>;
toggleDataset: (name: string) => void;
selectDataset: (name: string) => void;
}
export const useDatasetStore = create<DatasetState>((set, get) => ({
datasets: [],
selectedDatasets: [],
isLoading: false,
error: null,
fetchDatasets: async () => {
set({ isLoading: true, error: null });
try {
const { datasets } = await listDatasets();
set({ datasets, isLoading: false });
} catch (e: any) {
set({ error: e.message, isLoading: false });
}
},
toggleDataset: (name: string) => {
const current = get().selectedDatasets;
const next = current.includes(name)
? current.filter((n) => n !== name)
: [...current, name];
set({ selectedDatasets: next });
},
selectDataset: (name: string) => {
set({ selectedDatasets: [name] });
},
}));
Key Components¶
pages/ChatPage.tsx¶
The main chat interface. Composes the message list, input form, and dataset selector.
// src/pages/ChatPage.tsx
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useSessionStore } from "../state/sessionStore";
import { useChatStore } from "../state/chatStore";
import { useDatasetStore } from "../state/datasetStore";
import { ChatMessage } from "../components/ChatMessage";
import { ChatInput } from "../components/ChatInput";
import { DatasetSelector } from "../components/DatasetSelector";
import { useStreaming } from "../hooks/useStreaming";
export function ChatPage() {
const { isAuthenticated } = useSessionStore();
const { messages, isLoading } = useChatStore();
const { datasets, selectedDatasets, fetchDatasets } = useDatasetStore();
const { sendMessage } = useStreaming();
const navigate = useNavigate();
useEffect(() => {
if (!isAuthenticated) {
navigate("/login");
return;
}
fetchDatasets();
}, [isAuthenticated]);
const handleSend = (query: string) => {
if (selectedDatasets.length === 0) return;
sendMessage(selectedDatasets, query);
};
return (
<div className="flex h-screen">
<aside className="w-64 border-r p-4 overflow-y-auto">
<DatasetSelector
datasets={datasets}
selected={selectedDatasets}
/>
</aside>
<main className="flex-1 flex flex-col">
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} />
))}
</div>
<ChatInput
onSend={handleSend}
disabled={isLoading || selectedDatasets.length === 0}
/>
</main>
</div>
);
}
components/ChatMessage.tsx¶
Renders a single message bubble with role-based styling.
// src/components/ChatMessage.tsx
import type { ChatMessage as ChatMessageType } from "../state/chatStore";
interface Props {
message: ChatMessageType;
}
export function ChatMessage({ message }: Props) {
const isUser = message.role === "user";
return (
<div className={`flex ${isUser ? "justify-end" : "justify-start"}`}>
<div
className={`max-w-2xl rounded-lg px-4 py-3 ${
isUser
? "bg-purple-600 text-white"
: "bg-gray-100 text-gray-900"
}`}
>
<p className="whitespace-pre-wrap">{message.content}</p>
{message.isStreaming && (
<span className="inline-block w-2 h-4 bg-current animate-pulse ml-1" />
)}
{message.documents && message.documents.length > 0 && (
<div className="mt-2 pt-2 border-t border-gray-300 text-sm">
<p className="font-semibold mb-1">Sources:</p>
<ul className="space-y-1">
{message.documents.map((doc, i) => (
<li key={i} className="text-gray-600">
{doc.filename ?? doc.filepath ?? `Document ${i + 1}`}
</li>
))}
</ul>
</div>
)}
</div>
</div>
);
}
components/ChatInput.tsx¶
A text input with submit button.
// src/components/ChatInput.tsx
import { useState, type FormEvent } from "react";
interface Props {
onSend: (message: string) => void;
disabled?: boolean;
}
export function ChatInput({ onSend, disabled }: Props) {
const [input, setInput] = useState("");
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = input.trim();
if (!trimmed) return;
onSend(trimmed);
setInput("");
};
return (
<form onSubmit={handleSubmit} className="border-t p-4 flex gap-2">
<input
type="text"
value={input}
onChange={(e) => setInput(e.target.value)}
placeholder="Ask a question..."
disabled={disabled}
className="flex-1 rounded-lg border px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<button
type="submit"
disabled={disabled || !input.trim()}
className="rounded-lg bg-purple-600 px-6 py-2 text-white font-medium hover:bg-purple-700 disabled:opacity-50"
>
Send
</button>
</form>
);
}
components/DatasetSelector.tsx¶
A list of datasets with selection checkboxes.
// src/components/DatasetSelector.tsx
import { useDatasetStore, type Dataset } from "../state/datasetStore";
interface Props {
datasets: Dataset[];
selected: string[];
}
export function DatasetSelector({ datasets, selected }: Props) {
const { toggleDataset } = useDatasetStore();
return (
<div>
<h2 className="text-lg font-semibold mb-3">Datasets</h2>
{datasets.length === 0 && (
<p className="text-gray-500 text-sm">Loading datasets...</p>
)}
<ul className="space-y-2">
{datasets.map((ds) => (
<li key={ds.name}>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selected.includes(ds.name)}
onChange={() => toggleDataset(ds.name)}
className="rounded"
/>
<span className="text-sm">{ds.name}</span>
</label>
</li>
))}
</ul>
</div>
);
}
components/LoginForm.tsx¶
A simple form that collects the API key and calls the session store's login action.
// src/components/LoginForm.tsx
import { useState, type FormEvent } from "react";
import { useSessionStore } from "../state/sessionStore";
export function LoginForm() {
const [apiKey, setApiKey] = useState("");
const { login, isLoading, error } = useSessionStore();
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
if (!apiKey.trim()) return;
await login(apiKey.trim());
};
return (
<form onSubmit={handleSubmit} className="max-w-md mx-auto mt-20 space-y-4">
<h1 className="text-2xl font-bold">Connect to Gaia</h1>
<p className="text-gray-600">Enter your Cohesity API key to get started.</p>
{error && (
<div className="bg-red-50 text-red-700 rounded-lg px-4 py-2 text-sm">
{error}
</div>
)}
<input
type="password"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your API key"
className="w-full rounded-lg border px-4 py-2 focus:outline-none focus:ring-2 focus:ring-purple-500"
/>
<button
type="submit"
disabled={isLoading || !apiKey.trim()}
className="w-full rounded-lg bg-purple-600 px-4 py-2 text-white font-medium hover:bg-purple-700 disabled:opacity-50"
>
{isLoading ? "Connecting..." : "Connect"}
</button>
</form>
);
}
Routing¶
App.tsx¶
// src/App.tsx
import { BrowserRouter, Routes, Route, Navigate } from "react-router-dom";
import { useEffect } from "react";
import { useSessionStore } from "./state/sessionStore";
import { ChatPage } from "./pages/ChatPage";
import { LoginPage } from "./pages/LoginPage";
function App() {
const { restoreSession, isAuthenticated } = useSessionStore();
useEffect(() => {
const savedSession = localStorage.getItem("sessionId");
if (savedSession) {
restoreSession(savedSession);
}
}, []);
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/chat"
element={
isAuthenticated ? <ChatPage /> : <Navigate to="/login" />
}
/>
<Route path="*" element={<Navigate to="/chat" />} />
</Routes>
</BrowserRouter>
);
}
export default App;
The Streaming Hook¶
hooks/useStreaming.ts¶
Connects the streaming endpoint to the chat store using fetch with ReadableStream.
// src/hooks/useStreaming.ts
import { useChatStore } from "../state/chatStore";
import { getSessionId } from "../api/client";
export function useStreaming() {
const {
addUserMessage,
addAssistantMessage,
updateAssistantMessage,
setAssistantComplete,
setConversationId,
setLoading,
conversationId,
} = useChatStore();
const sendMessage = async (datasetNames: string[], query: string) => {
addUserMessage(query);
setLoading(true);
const assistantId = `msg-assistant-${Date.now()}`;
addAssistantMessage(assistantId, "");
try {
const response = await fetch("/api/v1/ask/stream", {
method: "POST",
headers: {
"Content-Type": "application/json",
...(getSessionId() ? { "X-Session-ID": getSessionId()! } : {}),
},
body: JSON.stringify({
datasetNames,
queryString: query,
conversationId,
}),
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const reader = response.body!.getReader();
const decoder = new TextDecoder();
let accumulated = "";
let buffer = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.startsWith("data: ")) {
const data = line.slice(6);
try {
const parsed = JSON.parse(data);
if (parsed.responseString) {
accumulated += parsed.responseString;
updateAssistantMessage(assistantId, accumulated);
}
if (parsed.conversationId) {
setConversationId(parsed.conversationId);
}
if (parsed.finishReason) {
setAssistantComplete(
assistantId,
parsed.queryUid,
parsed.documents,
);
}
} catch {
accumulated += data;
updateAssistantMessage(assistantId, accumulated);
}
}
}
}
setAssistantComplete(assistantId);
} catch (error: any) {
updateAssistantMessage(assistantId, `Error: ${error.message}`);
setAssistantComplete(assistantId);
} finally {
setLoading(false);
}
};
return { sendMessage };
}
Styling Recommendations¶
Tailwind is the recommended approach. Install @tailwindcss/vite and import Tailwind in your CSS entry point:
All components in this guide use Tailwind utility classes. The purple color scheme matches the MkDocs theme used in this documentation.
Next Steps¶
- Session Management — Deep dive into the session lifecycle.
- Error Handling — Frontend error boundaries and toast notifications.
- Streaming Responses — Advanced streaming patterns.