DEV Community

Prateek Mohan
Prateek Mohan

Posted on

React 19, Zustand, a 5-Provider AI Broker, and P2P Multiplayer — How I Architected My Learning Platform

Posted by Prateek | March 2026


I shipped React 19 in production when it was still RC. Not because I love living dangerously — well, maybe a little — but because I was building PatternMaster from scratch and there was no legacy to protect. When you're starting fresh, you pick the best tools available right now, not the ones that were best two years ago.

This is the architecture post. Blog 1 covered the overall vision, Blog 2 covered the game mechanics. This one is for the people who want to know how it's actually built — the state management, the AI plumbing, the theme system, the database decisions. Grab a coffee.

PatternMaster Dashboard


The Stack (and Why I Made These Choices)

Let me be upfront: I didn't pick these tools to flex. I picked them because each one solved a specific problem I had.

React 19.2.0 + Vite 7.1.9 — React 19 shipped concurrent features that genuinely matter for an app that's doing heavy AI streaming and 3D rendering simultaneously. The server actions model didn't apply here (Express backend), but the use() hook and improved Suspense boundaries cleaned up a lot of ugly loading state code. Vite 7 is just fast. It takes about 300ms to cold-start the dev server. I'll never go back to webpack.

TailwindCSS 4.1.14 — Tailwind v4 rewrote the engine in Rust and dropped the PostCSS dependency. Build times dropped noticeably. But the real win for me was the new CSS variable integration — I'll explain this later when I get to the theme system.

Zustand 5.0.10 — I tried Redux Toolkit first. For about a week. The amount of boilerplate for something as simple as "update a card's review interval" was embarrassing. Zustand lets me write state logic that reads like plain JavaScript. Zustand 5 dropped some legacy API surface and made TypeScript inference significantly better. Zero regrets.

Wouter 3 — React Router is a framework now. I didn't need a framework. I needed a router. Wouter is ~2.1KB, does exactly what I need (hash routing, params, programmatic navigation), and has zero opinions about my data fetching strategy. For a Vite SPA with an Express backend, it's perfect.

Drizzle ORM 0.39.3 + PostgreSQL — I tried Prisma first. Prisma is excellent but the generated client is chunky and the migration story felt heavy for a project that changes schema often. Drizzle is schema-as-code with SQL-first thinking. The drizzle-zod integration means my Zod validation schemas are derived from my database types automatically — no duplication.

Three.js 0.183.0 + React Three Fiber 9.5.0 — The 3D games (zombie FPS, runner, invaders) need actual WebGL. There's no HTML/CSS substitute for a procedural terrain zombie shooter. R3F makes Three.js composable with React's component model, which makes things like "conditionally render rain particles only when the weather system says so" natural.


State Architecture: Zustand + IndexedDB

This is probably the most interesting non-obvious decision in the whole project.

Early versions used localStorage for persistence. This worked fine until users started generating substantial content — 40+ AI-generated units, hundreds of review cards, visualization histories. localStorage is synchronous and has a 5MB cap. Hitting that cap caused silent failures that were a nightmare to debug.

The fix was switching to IndexedDB via idb-keyval, with localStorage as a silent fallback:

// client/src/lib/store/index.ts
import { get as idbGet, set as idbSet, del as idbDel } from 'idb-keyval';

const idbStorage: StateStorage = {
  getItem: async (name) => {
    try {
      return (await idbGet<string>(name)) ?? null;
    } catch {
      // IndexedDB can fail in private browsing on some browsers
      try { return localStorage.getItem(name); } catch { return null; }
    }
  },
  setItem: async (name, value) => {
    try {
      await idbSet(name, value);
    } catch {
      try { localStorage.setItem(name, value); } catch { /* quota exceeded */ }
    }
  },
  // ...
};
Enter fullscreen mode Exit fullscreen mode

The fallback chain is intentional. Safari private browsing blocks IndexedDB. Firefox sometimes blocks it in strict mode. Rather than showing an error, the app silently degrades to localStorage, and if that fails (quota exceeded), it just doesn't persist — the app still works, just without offline state. This is the right tradeoff for a learning tool.

The Partialize Problem

Zustand's persist middleware has a partialize option that lets you control exactly what gets serialized. Without it, you serialize everything — including base64-encoded AI-generated images that are sometimes 500KB each. I learned this the hard way.

partialize: (state) => ({
  units: state.units.map(stripHeavyUnitPayload).slice(-40),
  reviewCards: state.reviewCards,
  reviewCardState: state.reviewCardState,
  // ... other lightweight slices
}),
Enter fullscreen mode Exit fullscreen mode

stripHeavyUnitPayload strips the imageUrl field from unit images before persisting — the URLs are regenerated on demand anyway. The .slice(-40) keeps only the 40 most recent units. This brought storage usage from "frequently hitting quota" to "never hitting quota."

Slice Architecture

The store is split into four slices:

  • reviewSlice.ts — SM-2 spaced repetition state (card intervals, efactors, phases, due dates)
  • contentSlice.ts — Units and lesson plans (the AI-generated learning content)
  • gameSlice.ts — Active card selection, question pools, game preferences
  • index.ts — Composes the slices, handles migrations, owns the persistence config

The migration system has 16 versions. Every time I change the shape of persisted state, I add a migration step. This means existing users don't lose their data when the schema changes — their stored JSON gets transformed forward. It's tedious but it's the right thing to do.

Settings and AI Providers

Server Sync Pattern

The store is the source of truth for the UI, but everything important gets synced to the server. On login:

// Hydrate from server, merge with local state
Promise.allSettled([
  fetchReviewSnapshot(),        // SM-2 states from DB
  fetch("/api/user/content"),   // Units and lesson plans
  fetch("/api/user/settings"),  // Profile, theme, AI settings
]).then(([reviewResult, contentResult, settingsResult]) => {
  // Reconcile: server wins for review states (prevents grade drift)
  // Merge: local wins for content (offline edits preserved)
  // Clean up orphaned cards (units deleted on another device)
});
Enter fullscreen mode Exit fullscreen mode

After hydration, changes write to both Zustand (instant UI update) and the server (debounced, 2 second delay). This means the UI is always snappy and the server always eventually catches up. The debounce prevents a flood of requests when someone is rapidly grading review cards.


The AI Broker: 5 Providers, One Interface

This is the part I'm most proud of architecturally. Every AI call in the app goes through a single endpoint: POST /api/ai/complete. Behind that endpoint is a broker that handles provider selection, fallback, budget enforcement, deduplication, and observability.

Admin Panel — AI Observability

The Core Request Flow

// server/ai/broker.ts
export async function handleBrokerComplete(req: BrokerRequest): Promise<BrokerResponse> {
  // 1. Validate request schema (Zod)
  const parseResult = BrokerRequestSchema.safeParse(req);

  // 2. Validate context for subphase
  // (e.g., repair operations need specific context fields)
  const ctxValidation = validateContext(subPhase, context);

  // 3. Deduplication — prevents identical concurrent calls
  const promptHash = createHash(prompt + (imageDataUrl?.slice(0, 100) ?? ""));
  const dedupKey = dedupeKey(mode, phase, subPhase, promptHash, imageHash);

  if (inFlight.has(dedupKey)) {
    console.info(`[broker] Dedup hit for ${dedupKey}`);
    return inFlight.get(dedupKey)!;  // Return the in-flight promise
  }

  // 4. Get ordered provider list based on policy
  const providerPairs = getProviderOrder(phase, subPhase, aiSettings);

  // 5. Build prompt with system template + context
  const systemTemplate = getSystemTemplate(subPhase);
  const builtPrompt = buildPrompt(prompt, context, systemTemplate);

  // 6. Execute with fallback chain
  // 7. Record workflow run for observability
  // 8. Return response
}
Enter fullscreen mode Exit fullscreen mode

The deduplication map (inFlight) is a plain Map<string, Promise<BrokerResponse>>. If the same prompt is requested twice before the first one finishes, the second caller gets the same Promise. This happens more often than you'd think — streaming responses, retries, React StrictMode double-renders.

The Policy Engine

Every AI request has a phase and subPhase. The phase is things like unitGeneration, tutorChat, reviewCardGen. The subPhase is more granular — repair, tool-select, streaming, etc.

// server/ai/policy.ts
export function enforcePolicy(phase: AIPhase, subPhase: AISubPhase): EnforcedPolicy {
  const phaseCfg = PHASE_POLICY[phase];

  // Strict subphases (repair, tool-select) get hard caps
  if (isStrictSubPhase(phase, subPhase)) {
    return {
      maxModels: 1,      // Only one model attempt
      maxProviders: 1,   // Only one provider attempt
      timeoutMs: phaseCfg.timeoutMs,
    };
  }

  return {
    maxModels: phaseCfg.maxModelsPerProvider,
    maxProviders: phaseCfg.maxProviders,
    timeoutMs: phaseCfg.timeoutMs,
    maxOutputTokens: phaseCfg.maxOutputTokens,
  };
}
Enter fullscreen mode Exit fullscreen mode

Why cap repair subphases to 1x1? Because repair operations are idempotent JSON fixes — if the first call returns garbage JSON, retrying with a different provider usually just returns different garbage. Better to fail fast and surface the error than to burn 3 providers in sequence.

Free Tier vs Paid Tier Routing

This was the trickiest part to get right. The app supports two modes:

  • Free tier: User has no personal API keys. The platform uses its own API keys (stored as DEFAULT_GEMINI_KEY, DEFAULT_GROQ_KEY, etc. in env). Limited to a smaller model (gemini-3.1-flash-lite-preview) with stricter budget caps.
  • Paid tier: User brings their own API keys via Settings. Gets access to the full fallback chain (Gemini → OpenAI → Groq → Claude) and their preferred primary provider.
export function isFreeTier(aiSettings: AISettings): boolean {
  return !(
    hasKey(aiSettings.geminiKey) ||
    hasKey(aiSettings.openaiKey) ||
    hasKey(aiSettings.claudeKey) ||
    hasKey(aiSettings.groqKey)
  );
}
Enter fullscreen mode Exit fullscreen mode

Free tier users still get a functional app — just with less powerful models and more conservative timeouts. It's the right product decision: let people try it before committing to API key management.

Web Grounding via DuckDuckGo

The tutor chatbot can optionally search the web for context. Rather than paying for a search API, I built a DuckDuckGo scraper:

export async function fetchDuckDuckGoGrounding(query: string): Promise<string> {
  const normalized = query.replace(/\s+/g, " ").trim().slice(0, 240);

  // Try instant answers API first
  const instantEndpoint = `https://api.duckduckgo.com/?q=${encodeURIComponent(normalized)}&format=json&no_html=1`;
  const data = await fetch(instantEndpoint).then(r => r.json());

  if (data?.AbstractText) snippets.push(`- ${data.AbstractText}`);
  // ... collect related topics

  // Fall back to HTML scraping if instant answers are sparse
  if (snippets.length < 3) {
    const htmlRes = await fetch(`https://duckduckgo.com/html/?q=${...}`);
    // Parse result snippets from HTML
  }

  return `Web search context (DuckDuckGo):\n${snippets.join("\n")}`;
}
Enter fullscreen mode Exit fullscreen mode

The results get prepended to the AI prompt as grounding context. It's not perfect — DuckDuckGo's instant answer API is hit-or-miss — but it's free and usually good enough for CS/DSA questions.

Workflow Observability

Every AI call gets logged to the workflow_runs table:

// schema.ts
export const workflowRuns = pgTable("workflow_runs", {
  userId: text("user_id"),
  phase: text("phase").notNull(),
  provider: text("provider").notNull(),
  model: text("model").notNull(),
  inputTokens: integer("input_tokens"),
  outputTokens: integer("output_tokens"),
  latencyMs: integer("latency_ms").notNull(),
  status: text("status").notNull(),  // 'success' | 'error' | 'timeout'
});
Enter fullscreen mode Exit fullscreen mode

The admin panel visualizes this in real time — which providers are being hit, average latency per phase, error rates. When Gemini has an outage, I can see it immediately in the dashboard. When a particular prompt phase is consistently timing out, I know to increase the budget for that phase.


The 17-Variable CSS Theme System

Every color in the entire app flows through 17 CSS custom properties. No hardcoded hex values anywhere. This is a rule I enforced from day one and it paid off enormously.

Theme Settings

The Token Set

// client/src/lib/themes.ts
export type ThemeTokenKey =
  | "--color-brand-primary"       // Main accent (cyan, green, gold...)
  | "--color-brand-primary-dark"  // Darker variant for hover states
  | "--color-brand-secondary"     // Secondary accent
  | "--color-brand-background"    // Page background
  | "--color-brand-card"          // Card background
  | "--color-brand-text"          // Primary text
  | "--color-brand-text-light"    // Muted text
  | "--color-surface-1"           // Primary surface (modals, sheets)
  | "--color-surface-2"           // Secondary surface
  | "--color-surface-border"      // Border color
  | "--app-bg-start"              // Gradient background start
  | "--app-bg-mid"                // Gradient background mid
  | "--app-bg-end"                // Gradient background end
  | "--color-surface-code"        // Code block background
  | "--color-surface-code-border" // Code block border
  | "--color-text-code"           // Code text color
  | "--app-glow-1"                // Primary glow (box-shadows, halos)
  | "--app-glow-2"                // Secondary glow
  | "--app-glow-3";               // Tertiary glow
Enter fullscreen mode Exit fullscreen mode

The four built-in themes:

  • Hyper Dark — Deep space, holographic cyan, mono font. This is the default and the one you see in all the screenshots. hsl(186 100% 56%) is the brand primary — that vivid cyan that feels like a holographic HUD.
  • Arcade Neon — Electric green on near-black, purple highlights. Feels like a CRT monitor from 1988.
  • Cyber-Dojo — Gold and crimson, like a dojo with a GPU. The most dramatic one.
  • Calm Latte — Warm off-whites and browns. For people who want to study without feeling like they're piloting a spacecraft.

AI-Generated Custom Themes

The wild part: users can describe a theme in natural language ("dark purple with gold accents, corporate feel") and the AI generates a complete ThemePreset object — all 17 token values plus the style axes:

interface ThemeStyle {
  borderRadius: "sharp" | "rounded" | "pill";
  fontVibe: "mono" | "tech" | "elegant" | "casual";
  glowIntensity: "none" | "subtle" | "medium" | "intense";
  animationLevel: "none" | "calm" | "lively" | "intense";
  decorativeOverlay: boolean;
}
Enter fullscreen mode Exit fullscreen mode

The style object drives dynamic CSS generation — generateCustomThemeCSS() produces about 500 lines of CSS including keyframe animations (aiThemePulse, aiThemeShimmer), backdrop filters, font family overrides, and responsive border radius remapping. All injected into <head> at runtime.

The injection approach means no flash of unstyled content — the theme is applied synchronously from Zustand state before the first paint.

Full Theme Settings View


Cosmo: The Animated Mascot System

Every page in the app has a floating mascot called Cosmo. It's a WebM animation player that reacts to page navigation, user state, and random ambient timers.

// client/src/components/avatar/CosmoFloating.tsx

// Page-specific greeting animations
const PAGE_ANIMATIONS: Record<string, CosmoAnimation> = {
  "/": "wink",
  "/review": "reading",
  "/play": "celebrate",
  "/learn": "hint",
  "/visualize": "explaining",
};
Enter fullscreen mode Exit fullscreen mode

When you navigate to the Review page, Cosmo plays a "reading" animation — bookish, focused. When you navigate to Play, Cosmo does a "celebrate" animation — arms out, excited. It's a small thing but it makes the app feel alive.

Weighted Random Idle Animations

Between events, Cosmo plays ambient animations on a randomized schedule:

const IDLE_VARIETY: CosmoAnimation[] = [
  "idle", "idle", "idle",  // 3x weighted toward plain idle
  "wink", "reading", "think",
  "nod", "wave", "hint",
  "surprise", "confused",
];
Enter fullscreen mode Exit fullscreen mode

The triple-weighting of "idle" means Cosmo spends most of its time in a calm bobbing state. The variety animations fire occasionally — a wink here, a confused look there. It prevents the mascot from feeling like a broken GIF loop without being distracting.

Smart Contextual Suggestions

Cosmo isn't purely decorative. It watches the store:

const suggestions = useMemo(() => {
  const dueCount = reviewCards.filter((c) => {
    const st = reviewCardState[c.id];
    return st && st.phase === "review" && st.dueAt <= Date.now();
  }).length;

  const activeEntry = Object.entries(lessonSessions)
    .find(([, s]) => s && s.step > 0);
  const activeUnit = activeEntry
    ? units.find((u) => u.id === activeEntry[0])
    : null;

  return getCosmoSuggestions({
    currentPath: location,
    dueReviewCount: dueCount,
    activeLesson: activeUnit ? { id: activeUnit.id, title: activeUnit.title } : null,
    totalUnits: units.length,
  });
}, [location, units, reviewCards, reviewCardState, lessonSessions]);
Enter fullscreen mode Exit fullscreen mode

If you have 15 cards due for review and you're on the Play page, Cosmo gently suggests you might want to review first. It's not pushy — just contextually aware.

The mascot system has 17 animations (idle, wink, celebrate, confused, explaining, hint, learning, loading, reading, recursion, thinking, wave, and more), each a ~2MB WebM that gets lazy-loaded on demand.

Tutor Page with Nova/Cosmo


The Database Schema: 17 Tables

The schema lives in shared/schema.ts (shared between client types and server ORM). Here's what's actually in there:

Table Purpose
users Auth — username, passwordHash, role
userProgress Completed/unlocked units, Parsons progress
flashcardStates Legacy SM-2 state (pre-migration)
generatedUnits Units with provenance metadata (which provider/model generated them)
trackedProblems In-progress AI generation jobs
reviewCards Card data with full JSONB blob
reviewCardStates SM-2 state per card (JSONB for flexibility)
userSettings Theme, profile, preferences
userContent Units + lesson plans as JSONB blob
userGameQuestionPrefs Selected cards + generated question pools
visualizations Saved AI-generated Remotion scenes
sharedModels 3D model blueprints shared across users
workflowRuns AI request audit log
promptVersions Prompt template versioning by SHA-256 hash
gameResults Game session results for progress tracking
conceptMastery Per-concept mastery signals
weaknessSignals Topics the system identifies as weak areas

The mix of JSONB blobs and normalized tables is intentional. userContent and userSettings are pure JSONB — the shape changes frequently as I add features, and I don't want a migration for every new field. reviewCardStates is JSONB for the SM-2 data (it's a known-shape struct, but I want to be able to add fields without migrations). Tables like workflowRuns are fully normalized because I query them with aggregations.

This dual-write pattern is practical, not principled. Pure relational would be more correct. But "more correct" and "ships faster" are often in tension, and for a solo project, shipping faster wins.

Learning Journey and Progress


What I'd Do Differently

1. Start with the broker design from day one. The first version of the AI integration was direct calls to Gemini from the frontend (yes, with the API key in client code — don't judge me, it was a prototype). Extracting this to a proper server-side broker took two solid days. If I'd designed the broker interface first, those two days would've been building features instead.

2. IndexedDB from the start. Same deal — started with localStorage, hit quota issues, had to migrate. The migration was painful because I had to write a data migration that ran in the browser and moved existing data. It worked, but it was a weekend I didn't enjoy.

3. The Wouter choice holds up. I've seen blog posts arguing you should use React Router for "real" apps. PatternMaster has 30+ routes, lazy loading, auth guards, and nested layouts. Wouter handles all of it. The "real app needs React Router" argument is vibes, not evidence.

4. TypeScript strictness from day 1. I started with strict: false and gradually tightened it. This was wrong. The type errors I found when tightening strictness were real bugs — nullable values being accessed unsafely, union types being narrowed incorrectly. Start strict, stay strict.

5. The theme system architecture is correct. The "17 CSS variables only, never hardcode" rule felt pedantic when I first imposed it. Six months later, with 4 built-in themes and AI-generated custom themes, I'm very glad I enforced it. Every component that uses var(--color-brand-primary) instead of #38bdf8 gets themes for free. Every component that had a hardcoded color was a bug I had to track down manually.


What's Next

The architecture is stable enough to build on. The next big thing is a proper multiplayer layer — players sharing a game session, answering questions in real time, competing for XP. The broker design already accommodates this (requests are user-scoped, not session-scoped). The schema has the concept of sessionId throughout. The pieces are there.

After that, the weak spot is the analytics layer. workflowRuns gives me AI observability, but weaknessSignals and conceptMastery are tables I've built but not yet fully populated. The vision is a recommendation engine that looks at your SM-2 state, your game performance, and your chat history, and tells you exactly what to study next. Right now it's just vibes. Soon it'll be data.

If you're building something similar — AI-powered, state-heavy, multi-provider — I hope the patterns here are useful. The broker pattern especially. The moment you have more than one AI provider, you want a layer of abstraction between your product code and the API calls. The deduplication alone has saved me from some very embarrassing race conditions.


PatternMaster is a personal project I'm building in public. The codebase covers React 19, Three.js FPS games, spaced repetition, multi-provider AI, and a lot of things I'll probably regret later. Posts in this series: [Blog 1 - Overview], [Blog 2 - Games], Blog 3 - Architecture (this one).

Questions, roasts, or architecture suggestions — find me at the usual places.


Tags: #react #zustand #typescript #ai #webdev #architecture #postgresql #threejs

Top comments (0)