Skip to content

ADR-26: Zustand Client State Management

🇰🇷 한국어 버전

DateAuthorRepos
2026-01-23@KubrickCodeweb

Context

The Specvital web platform requires ephemeral client-side state that differs from existing state management solutions:

State CategoryManaged ByExamples
Server StateTanStack QueryRepository data, analysis results, user profile
URL StatenuqsDashboard filters, search queries, view mode
Client StateNeededBackground task tracking, session ephemeral data

Problem

Background operations (analysis, spec generation) had critical limitations with modal-based UI:

  1. State lost when modals closed
  2. Polling stopped on page navigation
  3. No cross-page visibility of running tasks

User quote from Issue #240: "Users cannot track spec generation progress after closing modals or navigating away."

Constraints

  • App Router RSC Boundary: State management must respect client/server component boundary
  • Hydration Mismatch Risk: SSR + client state requires careful handling
  • Bundle Size Sensitivity: Dashboard performance is critical
  • Existing Stack Integration: Must coexist with TanStack Query and nuqs

Decision

Adopt Zustand for global client-side state management.

Rationale

  1. Completes state management trifecta: TanStack Query (server), nuqs (URL), Zustand (client)
  2. Minimal bundle impact (~3 KB gzipped)
  3. No Provider wrapper required
  4. Built-in middleware: persist, devtools
  5. Native useSyncExternalStore for React 18+ optimization
  6. TypeScript-first with full type inference

Options Considered

Option A: Zustand (Selected)

Characteristics:

  • ~3 KB gzipped bundle size
  • No Provider wrapper required
  • Hooks-based API with selective subscriptions

Strengths:

  • Zero boilerplate, immediate productivity
  • Works anywhere in component tree without setup
  • Redux DevTools compatible
  • Built-in persist middleware for storage sync

Weaknesses:

  • Less structured than Redux for very large codebases
  • Team must establish conventions (no enforced patterns)

Option B: Redux Toolkit

Characteristics:

  • ~10-15 KB gzipped
  • Provider wrapper required
  • RTK Query for server state

Evaluation: Rejected. RTK Query overlaps with existing TanStack Query. Additional boilerplate and provider requirements not justified for remaining client state needs.

Option C: React Context + useReducer

Characteristics:

  • 0 KB (built-in)
  • Provider wrapper required
  • Manual optimization needed

Evaluation: Rejected. Re-render issues with frequently-changing state. Context is designed for low-frequency updates, not dynamic state like task tracking.

Option D: Jotai (Atomic State)

Characteristics:

  • ~3.5 KB gzipped
  • Atom-based composition
  • Provider optional but recommended

Evaluation: Viable but not selected. Atom-based model requires different mental model. Zustand's store-based approach is more intuitive for simple task tracking needs.

Implementation

Store Structure

typescript
type TaskStoreState = {
  tasks: Map<string, BackgroundTask>;
};

type TaskStoreActions = {
  addTask: (task: Omit<BackgroundTask, "createdAt">) => void;
  updateTask: (id: string, updates: Partial<BackgroundTask>) => void;
  removeTask: (id: string) => void;
  clearCompletedTasks: () => void;
};

Key Files

FilePurpose
lib/background-tasks/task-store.tsMain Zustand store
lib/background-tasks/hooks.tsCustom hooks for consumers
lib/background-tasks/task-store.spec.tsUnit tests
lib/background-tasks/components/task-badge.tsxBadge with active task count

Patterns Used

PatternImplementation
Singleton StoreSingle useTaskStore with create()
Derived SelectorsuseActiveTasks computes filtered results
Shallow ComparisonuseShallow prevents unnecessary re-renders
Outside React AccessuseTaskStore.getState() for non-component usage
Manual PersistenceCustom sessionStorage sync (not persist middleware)

Persistence

Custom sessionStorage implementation instead of Zustand's persist middleware:

  • Storage key: specvital:background-tasks
  • SSR-safe with typeof window === "undefined" check
  • Silent error handling for corrupted storage or quota exceeded

Consequences

Positive

  • Minimal Integration Friction: No Provider needed, works immediately
  • Developer Experience: Single file per store with collocated state + actions
  • Performance: Selective subscriptions prevent unnecessary re-renders
  • Debugging: Redux DevTools compatibility

Negative

  • Team Consistency Risk: No enforced patterns
    • Mitigation: Document store organization conventions, code review enforcement
  • Limited Middleware Ecosystem: Fewer community middlewares than Redux
    • Mitigation: Core needs (persist, devtools) are covered
  • Next.js SSR Considerations: Store is module-level
    • Mitigation: Follow official Zustand Next.js guide

References

Internal

External

  • 8664cbc: feat(background-tasks): add global task store with persistence
  • ecb4434: feat(background-tasks): integrate Account Badge and Tasks Dropdown
  • fc99ce5: feat(background-tasks): add Dashboard active tasks section
  • 2e1e6df: refactor(dashboard): migrate use-reanalyze hook to TanStack Query polling

Open-source test coverage insights