Skip to content

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

Bash
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install

Install Dependencies

Bash
npm install zustand react-router-dom
npm install -D tailwindcss @tailwindcss/vite

Configure Vite Proxy

Add an API proxy so the dev server forwards /api requests to your FastAPI backend:

TypeScript
// 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

Text Only
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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TypeScript
// 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.

TSX
// 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.

TSX
// 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.

TSX
// 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.

TSX
// 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.

TSX
// 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

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.

TypeScript
// 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:

CSS
/* src/styles/globals.css */
@import "tailwindcss";

All components in this guide use Tailwind utility classes. The purple color scheme matches the MkDocs theme used in this documentation.

If you prefer scoped CSS, create .module.css files alongside your components:

CSS
/* ChatMessage.module.css */
.bubble {
  max-width: 42rem;
  border-radius: 0.5rem;
  padding: 0.75rem 1rem;
}
.user { background: #7c3aed; color: white; }
.assistant { background: #f3f4f6; color: #111827; }
TSX
import styles from "./ChatMessage.module.css";
// ...
<div className={`${styles.bubble} ${isUser ? styles.user : styles.assistant}`}>

Next Steps