How Zustand Stores and TanStack Query Coordinate Frontend State Management in Open Notebook
Open Notebook separates client-only UI state (managed by Zustand) from server-derived data (cached by TanStack Query), using query invalidation and side effects to keep both layers synchronized without prop drilling.
The Open Notebook frontend implements a strict architectural boundary between ephemeral interface preferences and remote API data. By leveraging Zustand for global UI stores and TanStack Query for declarative data fetching, the application maintains instantaneous local interactions while ensuring the backend remains the single source of truth for notebooks, notes, and sources.
The Architecture: Separating Client and Server State
The codebase distinguishes between two categories of state with different ownership models:
-
Zustand stores handle client-only state such as view modes, navigation breadcrumbs, authentication tokens, and theme preferences. These values never originate from the backend and are often persisted to
localStorageorsessionStorage. -
TanStack Query manages all server-derived data including notebooks, notes, sources, and search results. It provides a centralized cache, automatic deduplication, and background refetching through the
QueryClientsingleton.
This separation prevents UI logic from polluting remote data caches while ensuring asynchronous server updates do not trigger unnecessary component re-renders for static preferences.
Setting Up the Shared QueryClient
All components import a singleton QueryClient from frontend/src/lib/api/query-client.ts to guarantee a unified cache across the application:
import { QueryClient } from '@tanstack/react-query'
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: 2,
refetchOnWindowFocus: false,
},
mutations: { retry: 1 },
},
})
export const QUERY_KEYS = {
notebooks: ['notebooks'] as const,
sources: (id?: string) => ['sources', id] as const,
// ...
}
The exported QUERY_KEYS object provides type-safe cache keys used throughout the application for invalidation and prefetching.
Managing UI State with Zustand
Global UI preferences live in lightweight Zustand stores created with the persist middleware. In frontend/src/lib/stores/notebook-view-store.ts, the view mode persists across reloads:
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type NotebookViewMode = 'tile' | 'list'
interface NotebookViewState {
viewMode: NotebookViewMode
setViewMode: (mode: NotebookViewMode) => void
}
export const useNotebookViewStore = create<NotebookViewState>()(
persist(
(set) => ({
viewMode: 'tile',
setViewMode: (mode) => set({ viewMode: mode }),
}),
{ name: 'notebook-view-storage' }
)
)
Similarly, frontend/src/lib/stores/navigation-store.ts manages breadcrumb history, and frontend/src/lib/stores/auth-store.ts holds JWT tokens and login status.
Coordinating Mutations and Cache Invalidation
When server data changes, mutations invalidate relevant query keys to trigger UI updates. The SourceDetailContent component demonstrates this pattern:
const mutation = useMutation(api.updateSource, {
onSuccess: () => {
queryClient.invalidateQueries(QUERY_KEYS.sources())
},
})
const handleSave = async (updates) => {
await mutation.mutateAsync(updates)
}
After the invalidateQueries call, TanStack Query automatically refetches the stale data, ensuring components render the latest server state without manual cache manipulation.
Combining Both Layers in Components
Components frequently consume both systems simultaneously. In frontend/src/components/source/SourceDetailContent.tsx, the component reads server data via useQuery while updating navigation state in Zustand:
import { useQueryClient } from '@tanstack/react-query'
import { useNavigationStore } from '@/lib/stores/navigation-store'
const queryClient = useQueryClient()
const setReturnTo = useNavigationStore(state => state.setReturnTo)
// After fetching, update the navigation breadcrumb
useEffect(() => {
if (source) setReturnTo(`/sources/${source.id}`, source.title)
}, [source])
The CollapsibleColumn component in frontend/src/components/layout/CollapsibleColumn.tsx uses the notebook view store to toggle between layouts while fetching data through TanStack Query:
const { data: notebooks } = useQuery(
QUERY_KEYS.notebooks,
() => api.getNotebooks(),
{ enabled: true }
)
const setViewMode = useNotebookViewStore(state => state.setViewMode)
const onToggle = () => setViewMode(prev => (prev === 'tile' ? 'list' : 'tile'))
Components may also optimistically update UI stores immediately after mutations to avoid waiting for network roundtrips:
const handleCreateNotebook = async () => {
await mutation.mutateAsync(newNotebook)
setViewMode('tile') // Instant UI feedback via Zustand
}
Summary
- Zustand stores in
frontend/src/lib/stores/manage client-only state like view modes, navigation, and auth tokens, often persisting to browser storage. - TanStack Query handles all remote data fetching through the shared
QueryClientinfrontend/src/lib/api/query-client.ts, caching notebooks, sources, and notes. - Cache invalidation occurs via
queryClient.invalidateQueries()inside mutationonSuccesscallbacks, ensuring server changes reflect immediately in the UI. - Component coordination happens when hooks like
useQueryprovide server data while Zustand selectors provide UI preferences, withuseEffectbridging the two for side effects like breadcrumb updates.
Frequently Asked Questions
Why does Open Notebook use both Zustand and TanStack Query instead of just one state manager?
TanStack Query specializes in caching, deduplicating, and synchronizing server data, while Zustand provides a minimal, persistent store for values that never touch the backend—such as theme preferences, view modes, or navigation history. Using both prevents the complexity of mixing UI logic with cache management and avoids persisting server data that should remain ephemeral.
How does the application keep Zustand state synchronized after a server mutation?
Components typically update Zustand stores optimistically or immediately after a successful mutation. For example, after creating a notebook via useMutation, a component might call setViewMode('tile') from useNotebookViewStore to provide instant visual feedback, while TanStack Query handles refetching the actual notebook list in the background.
What happens to TanStack Query cache when a user logs out?
The authentication state lives in frontend/src/lib/stores/auth-store.ts, which provides the JWT to API calls. When the logout action clears the Zustand auth store, components typically also call queryClient.clear() to remove all cached server data, ensuring no sensitive information persists between sessions.
Can Zustand stores access TanStack Query data directly?
The codebase avoids direct coupling between stores and the query cache. Instead, components act as the integration layer: they read server data via useQuery hooks and write UI updates to Zustand via store actions. This unidirectional flow prevents circular dependencies and keeps the data flow predictable.
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 →