React Frontend State Management Architecture in Thunderbolt: Zustand, Context, and React Query

Thunderbolt’s React frontend combines Zustand for global stores, React Context for cross-cutting concerns, and React Query with PowerSync for real-time data synchronization.

The Thunderbird Thunderbolt codebase implements a layered state management strategy that separates global mutable state, asynchronous data fetching, and local UI concerns. This architecture leverages modern React patterns while integrating PowerSync’s real-time synchronization capabilities for a desktop email client environment.

Global State Management with Zustand

Thunderbolt uses Zustand to eliminate prop-drilling for application-wide mutable state. The stores are defined in standalone TypeScript files and consumed via hooks throughout the component tree.

Central Chat Store

The primary global store resides in chats/chat-store.ts. It manages the conversation list, active chat selection, and optimistic UI updates:

import { create } from 'zustand'
import { useShallow } from 'zustand/react/shallow'

export const useChatStore = create<ChatState>((set, get) => ({
  chats: [],
  activeChatId: null,
  setActiveChatId: (id) => set({ activeChatId: id }),
  addChat: (chat) => set((state) => ({ chats: [...state.chats, chat] })),
  // Additional mutating helpers for optimistic updates
}))

Components subscribe to specific slices using useShallow to prevent unnecessary re-renders:

import { useChatStore } from '../../chats/chat-store'
import { useShallow } from 'zustand/react/shallow'

export const ChatListItem = ({ thread }) => {
  const { activeChatId, setActiveChatId } = useChatStore(
    useShallow(state => ({
      activeChatId: state.activeChatId,
      setActiveChatId: state.setActiveChatId,
    }))
  )
  // Render logic using activeChatId
}

Additional Zustand Stores

Smaller scoped stores handle specific UI state:

Cross-Cutting Concerns via React Context

For infrastructure and configuration that changes infrequently but must be accessible deep in the tree, Thunderbolt implements React Context providers. These wrap subsystems in dedicated context objects consumed via useContext.

Context Source File Purpose
ThemeProviderContext lib/theme-provider.tsx Current theme (light/dark), setTheme function, and persistence
MCPProviderContext lib/mcp-provider.tsx Multi-Channel PowerSync server connection, server list, reconnect logic
TrayContext lib/tray.tsx System tray icon management, menu actions, click callbacks
HttpClientContext contexts/http-client-context.tsx Pre-configured HttpClient for internal API calls
DatabaseContext contexts/database-context.tsx PowerSync database instance and reactive helpers
AuthContext contexts/auth-context.tsx Current user, auth token, signIn, signOut methods
ContentViewContext content-view/context.tsx Content view pane mode (webview vs. native) and preview visibility
SignInModalContext contexts/sign-in-modal-context.tsx Sign-in modal and sync-setup modal visibility

Each provider follows a standard implementation pattern:

const ThemeProviderContext = createContext<ThemeProviderState | undefined>(undefined)

export function ThemeProvider({ children }: { children: ReactNode }) {
  const [theme, setTheme] = useState<Theme>('system')
  
  // Persistence and system preference logic
  
  return (
    <ThemeProviderContext.Provider value={{ theme, setTheme }}>
      {children}
    </ThemeProviderContext.Provider>
  )
}

export const useTheme = () => {
  const context = useContext(ThemeProviderContext)
  if (!context) throw new Error('useTheme must be used within ThemeProvider')
  return context
}

Synchronized Data Fetching with React Query and PowerSync

Thunderbolt integrates TanStack Query (React Query) via the @powersync/tanstack-react-query package to handle asynchronous data synchronization. This pattern appears throughout settings pages and task management views.

Real-time Database Integration

Components query the PowerSync database reactively, with automatic caching and background refetching:

import { useQuery } from '@powersync/tanstack-react-query'

export const ModelsList = () => {
  const { data: models = [], isLoading } = useQuery({
    queryKey: ['models'],
    queryFn: async () => {
      const res = await db.select().from(modelsTable).all()
      return res
    }
  })
  
  return (
    <div>
      {models.map(model => <ModelCard key={model.id} model={model} />)}
    </div>
  )
}

Files implementing this pattern include:

Local Component State Patterns

For UI-specific concerns that do not require global visibility, Thunderbolt uses standard React hooks.

Simple UI State with useState

Over 100 components employ useState for ephemeral state such as modal visibility, form inputs, and loading indicators. Examples include widgets/link-preview/display.tsx and layout/sidebar/sidebar-header.tsx.

Complex Forms with useReducer

When state logic involves multiple actions or complex transitions, Thunderbolt implements useReducer. The Preferences panel (settings/preferences.tsx) and Model editor (settings/models/index.tsx) use this pattern to centralize state transitions and keep component code readable.

Reusable Utility Hooks

The src/hooks/ directory contains stateless utility hooks that encapsulate common behavior:

These hooks do not manage global state but provide deterministic UI logic that components consume declaratively.

Architectural Flow and Provider Composition

The state management architecture follows a strict initialization sequence in src/index.tsx:

  1. Provider Wrapping: The root component nests all context providers (ThemeProvider, AuthProvider, DatabaseProvider, MCPProvider, etc.) to make infrastructure available throughout the tree
  2. Global Store Instantiation: Zustand stores initialize once on module import and remain available for subscription anywhere in the application
  3. Async Data Hydration: Components mount and invoke useQuery hooks, which fetch initial data from PowerSync and establish reactive subscriptions
  4. UI Interaction: User actions update local useState or dispatch to useReducer, while cross-component updates flow through Zustand stores or Context values, triggering automatic re-renders in subscribers

Summary

  • Zustand stores in chats/chat-store.ts and related files handle global mutable state accessible without prop-drilling, optimized with useShallow selectors
  • React Context providers in lib/ and contexts/ directories encapsulate infrastructure concerns (theme, auth, database, MCP, tray) for deep component access
  • React Query via PowerSync enables real-time data synchronization with automatic caching, implemented in settings and task views
  • Local state uses useState for simple UI concerns and useReducer for complex forms like the preferences panel
  • Utility hooks in src/hooks/ provide reusable behavior (throttle, debounce, auto-scroll) without state side effects

Frequently Asked Questions

What state management library does Thunderbolt use for global state?

Thunderbolt uses Zustand for global state management. The primary store is defined in chats/chat-store.ts and manages chat conversations, active thread selection, and optimistic UI updates. Components subscribe to specific store slices using the useShallow helper from zustand/react/shallow to prevent unnecessary re-renders.

How does Thunderbolt handle real-time data synchronization?

Real-time synchronization is implemented through React Query (TanStack Query) integrated with PowerSync via the @powersync/tanstack-react-query package. Components use the useQuery hook to select from the local PowerSync database, which automatically synchronizes with the backend. This pattern appears in settings/models/layout.tsx, tasks/index.tsx, and other data-heavy views, providing automatic caching and background refetching.

Why does Thunderbolt use both Zustand and React Context?

Thunderbolt uses Zustand for mutable global state that changes frequently and must be accessible across many components (like chat lists), while React Context provides dependency injection for infrastructure that is relatively stable but required deep in the component tree (themes, authentication, database connections, and the MCP provider). This separation optimizes performance: Zustand prevents context-induced re-render cascades for high-frequency updates, while Context cleanly injects singleton services without prop drilling.

Where is the chat state defined in the codebase?

The chat state is defined in src/chats/chat-store.ts. This Zustand store exports the useChatStore hook and contains the chats array, activeChatId, and action methods for managing conversations. Components like layout/sidebar/chat-list-item.tsx import this store to read and update chat-related state.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →