Skip to content

State & Data Flow

State management patterns using TanStack Query and React context.

State Categories

Category Technology Examples
Server State TanStack Query Sessions, chats, embeddings
Auth State BetterAuth Session, user, organizations
UI State React useState Form inputs, modals, selections
Persisted UI localStorage Tutorial progress

TanStack Query Setup

Provider

context/tanstack-provider.tsx

"use client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 60 * 1000, // 1 minute
      refetchOnWindowFocus: false
    }
  }
});

export function TanstackProvider({ children }) {
  return (
    <QueryClientProvider client={queryClient}>
      {children}
    </QueryClientProvider>
  );
}

Usage in Root Layout

// app/layout.tsx
<TanstackProvider>
  {children}
</TanstackProvider>

Query Patterns

Standard Query

const { data, isLoading, error } = useQuery({
  queryKey: ["sessions", organizationSlug],
  queryFn: () => fetchJSON("/sessions", token),
  enabled: Boolean(token && organizationSlug)
});

Query Key Structure

Query keys are arrays for proper cache management:

// Organization-scoped
["sessions", organizationSlug]
["embeddings", organizationSlug, { limit: 100 }]

// Resource-specific
["session", organizationSlug, sessionId]
["chat", organizationSlug, chatId]

// Nested resources
["session-logs", organizationSlug, sessionId]
["session-clips", organizationSlug, sessionId]

Dependent Queries

const { data: session } = useQuery({
  queryKey: ["session", sessionId],
  queryFn: () => fetchSession(sessionId)
});

const { data: logs } = useQuery({
  queryKey: ["session-logs", sessionId],
  queryFn: () => fetchLogs(sessionId),
  enabled: Boolean(session) // Only run when session exists
});

Polling

const { data: job } = useQuery({
  queryKey: ["training-job", jobId],
  queryFn: () => fetchJob(jobId),
  refetchInterval: (data) => 
    ["pending", "running"].includes(data?.status) ? 5000 : false
});

Mutation Patterns

Standard Mutation

const createSession = useMutation({
  mutationFn: (data: CreateSessionData) => 
    fetchJSON("/sessions", token, { method: "POST", body: data }),
  onSuccess: () => {
    toast.success("Session created");
    queryClient.invalidateQueries(["sessions", organizationSlug]);
  },
  onError: (error) => {
    toast.error("Failed to create session");
    console.error(error);
  }
});

Optimistic Updates

const sendMessage = useMutation({
  mutationFn: (prompt: string) => sendToAPI(prompt),
  onMutate: async (prompt) => {
    // Cancel outgoing refetches
    await queryClient.cancelQueries(["chat", chatId]);

    // Snapshot previous value
    const previousChat = queryClient.getQueryData(["chat", chatId]);

    // Optimistically update
    queryClient.setQueryData(["chat", chatId], (old) => ({
      ...old,
      messages: [...old.messages, { role: "user", content: prompt }]
    }));

    return { previousChat };
  },
  onError: (err, prompt, context) => {
    // Rollback on error
    queryClient.setQueryData(["chat", chatId], context.previousChat);
  },
  onSettled: () => {
    queryClient.invalidateQueries(["chat", chatId]);
  }
});

Cache Invalidation

// Single query
queryClient.invalidateQueries(["sessions", orgSlug]);

// Multiple queries
queryClient.invalidateQueries({
  predicate: (query) => 
    query.queryKey[0] === "sessions" || 
    query.queryKey[0] === "session"
});

// All queries matching prefix
queryClient.invalidateQueries({ queryKey: ["sessions"] });

Auth State

Session Hook

// From BetterAuth
const { data: session, isLoading } = authClient.useSession();

// User data
session?.user?.id
session?.user?.email
session?.user?.name

// Session metadata
session?.session?.activeTeamId

Token Management

// In useAuth hook
const { data: token } = useQuery({
  queryKey: ["auth-token"],
  queryFn: async () => {
    const result = await authClient.token();
    return result?.data?.token;
  },
  staleTime: 10 * 60 * 1000 // Cache 10 minutes
});

Active Organization

const activeOrg = authClient.useActiveOrganization();

// Sync with URL
useEffect(() => {
  if (organizationSlug && activeOrg?.slug !== organizationSlug) {
    authClient.organization.setActive({ organizationSlug });
  }
}, [organizationSlug]);

Context Providers

Role Context

context/role-provider.tsx

const RoleContext = createContext<{
  role: Role | null;
  isAdmin: boolean;
  isManager: boolean;
  isBasic: boolean;
} | null>(null);

export function RoleProvider({ children, requireRole }) {
  const [role, setRole] = useState<Role | null>(null);

  useEffect(() => {
    const fetchRole = async () => {
      const result = await authClient.organization.getActiveMemberRole();
      setRole(result.data?.role);
    };
    fetchRole();
  }, []);

  // Role enforcement logic...

  return (
    <RoleContext.Provider value={{ 
      role, 
      isAdmin: role === "admin" || role === "owner",
      isManager: role === "owner",
      isBasic: role === "member"
    }}>
      {children}
    </RoleContext.Provider>
  );
}

Local UI State

Component State

function SessionLogger() {
  const [formData, setFormData] = useState({});
  const [isSubmitting, setIsSubmitting] = useState(false);

  // ...
}

Cross-Tab Persistence

hooks/use-tutorial-progress.ts

function useTutorialProgress(pageId: string, stepIds: string[]) {
  const getProgress = () => {
    const stored = localStorage.getItem(`${pageId}-tutorial-progress`);
    return stored ? JSON.parse(stored) : {};
  };

  const progress = useSyncExternalStore(
    (callback) => {
      window.addEventListener("storage", callback);
      return () => window.removeEventListener("storage", callback);
    },
    getProgress,
    () => ({}) // Server snapshot
  );

  const completeStep = (stepId: string) => {
    const current = getProgress();
    current[stepId] = true;
    localStorage.setItem(`${pageId}-tutorial-progress`, JSON.stringify(current));
  };

  return { progress, completeStep };
}

Data Flow Diagram

┌─────────────────────────────────────────────────────────────┐
│                        Component                             │
│  ┌─────────────────┐  ┌─────────────────┐  ┌──────────────┐ │
│  │  useAuth()      │  │  useSession()   │  │  useState()  │ │
│  │  (token, user)  │  │  (session data) │  │  (UI state)  │ │
│  └────────┬────────┘  └────────┬────────┘  └──────────────┘ │
│           │                    │                             │
└───────────┼────────────────────┼─────────────────────────────┘
            │                    │
            ▼                    ▼
┌───────────────────┐   ┌───────────────────┐
│  TanStack Query   │   │  BetterAuth       │
│  QueryClient      │   │  Client           │
└─────────┬─────────┘   └─────────┬─────────┘
          │                       │
          ▼                       ▼
┌───────────────────┐   ┌───────────────────┐
│  fetchJSON()      │   │  Auth API         │
│  (REST API)       │   │  (BetterAuth)     │
└─────────┬─────────┘   └───────────────────┘
┌───────────────────┐
│  Core API         │
│  /api/v1/actions  │
└───────────────────┘

fetchJSON Utility

lib/utils.ts

export async function fetchJSON<T>(
  path: string,
  token: string,
  request?: RequestInit,
  timeoutMs = 30000
): Promise<T> {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), timeoutMs);

  const url = `${API_BASE_URL}${path}`;
  const isFormData = request?.body instanceof FormData;

  const response = await fetch(url, {
    ...request,
    signal: controller.signal,
    headers: {
      Authorization: `Bearer ${token}`,
      ...(isFormData ? {} : { "Content-Type": "application/json" }),
      ...request?.headers
    },
    body: isFormData ? request.body : JSON.stringify(request?.body)
  });

  clearTimeout(timeout);

  if (!response.ok) {
    throw new Error(`API error: ${response.status}`);
  }

  return response.json();
}