liildev

AI products have unusual data flow patterns — streaming, optimistic UI, partial states, long-running operations. The architecture decisions you make early determine how well you handle them.

architecturefrontendaisaas

Most frontend architecture advice is about scale. How do you handle 10,000 components? How do you prevent prop drilling? How do you organize files for a team of 20?

AI SaaS has different problems. The team is usually small. The component count is manageable. The real challenge is data flow that doesn't fit standard CRUD patterns.

What makes AI products different

Long-running, non-idempotent operations. A generation request isn't a GET — it has cost, side effects, and takes 2-15 seconds. You need to think about what happens during that time: what's disabled, what's loading, what's cancellable.

Streaming output. Standard React Query / SWR patterns assume you fetch data and it arrives. Streaming data arrives continuously and updates incrementally. Your state management needs to handle a value that changes 30 times a second.

Partial and error states mid-operation. A streaming generation can fail after partial output. What does your UI show? A blank state? The partial result with an error? The partial result with a retry option? All three are defensible. None are the default.

Credit/balance state. Mutations have cost. The balance that was valid when the user started a generation might not be valid when it ends (if they opened another tab). Your optimistic UI has to account for this.

The state categories

I think about AI product state in four buckets:

  1. Server state — user data, history, account info. React Query handles this.
  2. Operation state — is a generation running? what's its ID? can it be cancelled? This lives in a Zustand store or a React context scoped to the generation session.
  3. Stream state — the incremental output. This is ephemeral and lives in a ref or a streaming-specific state primitive.
  4. UI state — which panel is open, what the input says. Local component state.

Mixing these is where I see most AI frontend bugs. Stream state in React Query causes re-renders on every token. Server state in a streaming ref causes stale data after the stream ends.

The folder structure that works

features/
  generation/
    model.ts         # Types for generation state
    store.ts         # Zustand store for operation state
    api.ts           # React Query hooks for server state
    stream.ts        # Streaming logic (SSE consumer)
    ui/
      composer.tsx
      output.tsx
      controls.tsx

Feature-sliced architecture helps here because it enforces that generation logic lives together. The streaming logic and the UI that renders it are in the same feature, not split across hooks/ and components/.

The one decision that matters most

Pick a single source of truth for "is there a generation in progress." It sounds obvious. In practice, I've seen codebases where the answer lives in four different places — a React Query mutation's isLoading, a Zustand flag, a ref tracking the SSE connection, and a local component boolean. They disagree under edge cases. Users see stuck loading states.

Make one store own the generation lifecycle. Everything else derives from it.