How the Open Notebook React Frontend Is Structured with Zustand State Management and TanStack Query
Open Notebook combines Zustand for global UI state (themes, sidebar, auth) and TanStack Query for server-state synchronization, wired together through a layered provider stack in a Next.js 12+ application.
The open-source repository lfnovo/open-notebook implements a clean, scalable React frontend architecture that strictly separates client-side concerns from remote data. By pairing Zustand’s lightweight stores with TanStack Query’s robust caching layer, the codebase maintains predictable state management across complex AI-driven notebook interactions.
1. Zustand: The Global State Layer
All global stores live under frontend/src/lib/stores/ and follow a consistent pattern using the create factory with optional persist middleware.
Store Architecture and Persistence
Each store is typed with TypeScript interfaces and wrapped with the persist middleware to survive page reloads. The theme store demonstrates the canonical structure:
// frontend/src/lib/stores/theme-store.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
interface ThemeState {
theme: 'light' | 'dark' | 'system'
setTheme: (t: ThemeState['theme']) => void
getSystemTheme: () => 'light' | 'dark'
getEffectiveTheme: () => 'light' | 'dark'
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
setTheme: (t) => set({ theme: t }),
getSystemTheme: () => (window?.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'),
getEffectiveTheme: () => (get().theme === 'system' ? get().getSystemTheme() : get().theme),
}),
{ name: 'theme-store' }
)
)
Other stores follow identical patterns in the same directory, including sidebar-store.ts for UI layout state and auth-store.ts for authentication tokens.
Consuming Stores in Components
Components import the hook and select specific slices to optimize re-renders:
import { useThemeStore } from '@/lib/stores/theme-store'
function ThemeToggle() {
const theme = useThemeStore((s) => s.theme)
const setTheme = useThemeStore((s) => s.setTheme)
return (
<button onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}>
Switch to {theme === 'dark' ? 'light' : 'dark'} mode
</button>
)
}
The Sonner toast component reads the current theme via useThemeStore to style notifications correctly, demonstrating cross-component state access.
2. TanStack Query: Server-State Management
Server-state—data fetching, caching, background refetching, and mutation handling—is centralized in frontend/src/lib/api/.
Query Client Configuration
A singleton QueryClient instance in frontend/src/lib/api/query-client.ts defines default cache behavior:
// frontend/src/lib/api/query-client.ts
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
gcTime: 10 * 60 * 1000, // 10 minutes
retry: 2,
refetchOnWindowFocus: false,
},
mutations: { retry: 1 },
},
})
Centralized query keys are exported from the same file to ensure consistency across the application.
Provider Integration
The QueryProvider component wraps the entire application via the root layout:
// frontend/src/components/providers/QueryProvider.tsx
'use client'
import { QueryClientProvider } from '@tanstack/react-query'
import { queryClient } from '@/lib/api/query-client'
export function QueryProvider({ children }: { children: React.ReactNode }) {
return <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
}
Data Fetching and Mutations
Components use useQuery for reads and useMutation for writes, invalidating cached queries on success:
import { useQuery } from '@tanstack/react-query'
import { QUERY_KEYS } from '@/lib/api/query-client'
import { fetchNotebooks } from '@/lib/api/notebooks'
function NotebookList() {
const { data, isLoading, error } = useQuery({
queryKey: QUERY_KEYS.notebooks,
queryFn: fetchNotebooks,
})
if (isLoading) return <p>Loading…</p>
return <ul>{data?.map((n) => <li key={n.id}>{n.title}</li>)}</ul>
}
Mutations automatically invalidate related queries to keep UI synchronized:
import { useMutation, useQueryClient } from '@tanstack/react-query'
import { QUERY_KEYS } from '@/lib/api/query-client'
import { updateSource } from '@/lib/api/sources'
function SourceDetail({ sourceId }: { sourceId: string }) {
const queryClient = useQueryClient()
const mutation = useMutation({
mutationFn: (payload) => updateSource(sourceId, payload),
onSuccess: () => queryClient.invalidateQueries({ queryKey: QUERY_KEYS.source(sourceId) }),
})
return <button onClick={() => mutation.mutateAsync({ title: 'Updated' })}>Save</button>
}
Real-world usage appears in SourceDetailContent.tsx and GeneratePodcastDialog.tsx, which combine mutations with Zustand stores for modal state.
3. Provider Composition and App Structure
The root layout in frontend/src/app/layout.tsx composes providers in a specific order:
<ThemeProvider>
<QueryProvider>
<ModalProvider>
{/* app content */}
</ModalProvider>
</QueryProvider>
</ThemeProvider>
This hierarchy ensures Zustand stores are available for immediate UI state (themes, navigation) while TanStack Query handles asynchronous data fetching underneath.
Summary
- Zustand stores live in
frontend/src/lib/stores/and manage synchronous UI state (theme, sidebar, auth) with optional persistence. - TanStack Query handles asynchronous server-state via a singleton
QueryClientconfigured infrontend/src/lib/api/query-client.ts. - Query keys are centralized to prevent cache inconsistencies and enable targeted invalidation.
- Provider composition in
frontend/src/app/layout.tsxlayers context from global (theme) to specific (modals), ensuring predictable state access. - Real components like
SourceDetailContent.tsxdemonstrate the pattern: Zustand for local UI control, TanStack Query for remote data mutations.
Frequently Asked Questions
What is the difference between Zustand and TanStack Query in this architecture?
Zustand manages client-only global state such as theme preferences, sidebar visibility, and authentication tokens that do not require server synchronization. TanStack Query handles all data that originates from or modifies the backend API, providing caching, deduplication, and automatic background refetching.
How does state persist across browser reloads?
Selected Zustand stores use the persist middleware to serialize state to localStorage. The theme store, for example, persists the user's selected mode (light, dark, or system) under the key theme-store, rehydrating automatically on app startup.
Where are query keys defined and why does it matter?
Query keys are centralized in frontend/src/lib/api/query-client.ts as the QUERY_KEYS object. Centralizing keys prevents typo-induced cache misses and ensures that mutations can precisely invalidate related queries using the same key constructor functions.
How do components trigger UI updates after mutating data?
Components call queryClient.invalidateQueries() inside the mutation's onSuccess callback. This marks the cached data as stale, prompting TanStack Query to refetch in the background and automatically re-render any subscribed components with fresh data.
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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →