How Frontend-Backend Communication Works Using Zustand and TanStack Query in Open Notebook
Open Notebook uses Zustand for synchronous UI state and TanStack Query for asynchronous server state, with automatic cache invalidation keeping the React frontend synchronized with the FastAPI backend.
The lfnovo/open-notebook repository implements a modern React architecture that cleanly separates local interface concerns from remote data management. The frontend leverages Zustand to handle persistent UI preferences like themes and navigation states, while TanStack Query manages all HTTP communication with the backend API. This dual-store pattern eliminates prop drilling and ensures components re-render automatically when either local settings change or server data updates.
Architecture Overview
Open Notebook’s frontend architecture divides state into two distinct layers:
-
Zustand (Client State) – Handles synchronous, browser-specific UI concerns such as theme selection, sidebar collapse states, and navigation shortcuts. These stores persist to
localStorageusing thepersistmiddleware. -
TanStack Query (Server State) – Manages asynchronous data fetching, caching, and background synchronization with the FastAPI backend. It provides declarative hooks for GET, POST, PUT, and DELETE operations.
Both systems are initialized at the root of the application through provider components (QueryProvider, ThemeProvider) that wrap the React tree, making cached data and UI preferences available throughout the component hierarchy.
Setting Up the Query Client
QueryClient Configuration
The global query client is instantiated in frontend/src/lib/api/query-client.ts with standardized cache policies and retry logic:
// 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 },
},
})
This configuration ensures that data remains fresh for five minutes before background refetching and garbage collects unused cache entries after ten minutes.
Centralized Query Keys
To prevent cache invalidation errors, the file exports a QUERY_KEYS object that standardizes key construction across the application:
export const QUERY_KEYS = {
notebooks: ['notebooks'] as const,
notebook: (id: string) => ['notebooks', id] as const,
// … other keys
}
The QueryProvider component in frontend/src/components/providers/QueryProvider.tsx then supplies this client to the React tree:
// 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>
)
}
Fetching and Mutating Server Data
Using useQuery for Data Fetching
Domain-specific hooks wrap TanStack Query’s useQuery to fetch data from the FastAPI backend. For example, useNotebooks in frontend/src/lib/hooks/use-notebooks.ts retrieves notebook lists:
// src/lib/hooks/use-notebooks.ts
import { useQuery } from '@tanstack/react-query'
import { notebooksApi } from '@/lib/api/notebooks'
import { QUERY_KEYS } from '@/lib/api/query-client'
export function useNotebooks(archived?: boolean) {
return useQuery({
queryKey: [...QUERY_KEYS.notebooks, { archived }],
queryFn: () => notebooksApi.list({ archived, order_by: 'updated desc' }),
})
}
This hook returns { data, isLoading, error } and automatically subscribes the component to the query cache. Multiple components calling useNotebooks() share the same cached data, eliminating redundant HTTP requests.
Using useMutation for Updates
Mutations handle create, update, and delete operations. The useCreateNotebook hook demonstrates how to invalidate related queries after a successful mutation:
export function useCreateNotebook() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (data) => notebooksApi.create(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: QUERY_KEYS.notebooks })
},
})
}
When invalidateQueries executes, TanStack Query automatically refetches the notebook list, ensuring all components displaying notebooks receive the updated dataset without manual refresh.
Managing Local UI State with Zustand
Persistent Theme Store Example
Zustand stores manage UI state that never needs to reach the server. The theme store in frontend/src/lib/stores/theme-store.ts demonstrates the persist middleware for cross-session storage:
// src/lib/stores/theme-store.ts
import { create } from 'zustand'
import { persist } from 'zustand/middleware'
export type Theme = 'light' | 'dark' | 'system'
interface ThemeState {
theme: Theme
setTheme: (theme: Theme) => void
}
export const useThemeStore = create<ThemeState>()(
persist(
(set, get) => ({
theme: 'system',
setTheme: (theme) => {
set({ theme })
// Immediately apply to document
if (typeof window !== 'undefined') {
const root = window.document.documentElement
const effective = theme === 'system' ? get().getSystemTheme() : theme
root.classList.remove('light', 'dark')
root.classList.add(effective)
root.setAttribute('data-theme', effective)
}
},
// … helpers omitted for brevity
}),
{ name: 'theme-storage', partialize: (s) => ({ theme: s.theme }) }
)
)
Components consume this state via a simple hook, receiving instantaneous updates without asynchronous overhead:
import { useTheme } from '@/lib/stores/theme-store'
function ThemeToggle() {
const { theme, setTheme, isDark } = useTheme()
return (
<button onClick={() => setTheme(isDark ? 'light' : 'dark')}>
Switch to {isDark ? 'light' : 'dark'} mode
</button>
)
}
Other UI State Stores
The repository contains additional Zustand stores located in frontend/src/lib/stores/:
- navigation-store.ts – Tracks "return-to" navigation states and history shortcuts
- sidebar-store.ts – Manages sidebar collapse/expand state
- auth-store.ts – Stores authentication flags and session tokens
Each uses the persist middleware to survive page reloads, ensuring user preferences remain intact across sessions.
Integrating Both State Systems
Real components combine both libraries. The NotebookList component in frontend/src/app/(dashboard)/notebooks/components/NotebookList.tsx demonstrates this integration:
// src/app/(dashboard)/notebooks/components/NotebookList.tsx
import { useNotebooks } from '@/lib/hooks/use-notebooks'
import { useTheme } from '@/lib/stores/theme-store'
export default function NotebookList() {
const { data: notebooks, isLoading } = useNotebooks()
const { theme, toggleTheme } = useTheme()
if (isLoading) return <div>Loading…</div>
return (
<section className={theme}>
<button onClick={toggleTheme}>Toggle Theme</button>
<ul>
{notebooks?.map((nb) => (
<li key={nb.id}>{nb.title}</li>
))}
</ul>
</section>
)
}
Execution flow:
useNotebookstriggers a GET request to/notebooksvianotebooksApi.list()and caches the result underQUERY_KEYS.notebooksuseThemereads the current theme from the Zustand store synchronously- When the user toggles the theme, the Zustand store updates instantly—no network request occurs
- When a mutation elsewhere creates a notebook,
invalidateQueriestriggers a background refetch, andNotebookListre-renders with fresh data
Summary
- Zustand handles synchronous UI state (themes, navigation, sidebar) and persists it to
localStoragevia thepersistmiddleware - TanStack Query manages asynchronous server state with automatic caching, background refetching, and query deduplication
- Cache invalidation occurs through
queryClient.invalidateQueries()in mutationonSuccesscallbacks, ensuring UI consistency - Centralized query keys in
query-client.tsprevent typos and ensure consistent cache management across the application - Provider composition in the root layout wraps the app with both
QueryClientProviderand Zustand context providers
Frequently Asked Questions
How does TanStack Query know when to refetch data?
TanStack Query refetches data when query keys are invalidated using queryClient.invalidateQueries(). In Open Notebook, mutation hooks call this method in their onSuccess callbacks, automatically triggering refetches for any component subscribed to those query keys. The library also supports background refetching when the window regains focus or network reconnects, though refetchOnWindowFocus is disabled in this codebase.
Why use Zustand instead of React Context for UI state?
Zustand provides better performance for high-frequency updates and eliminates the boilerplate associated with Context providers. The persist middleware also handles localStorage serialization automatically, whereas React Context requires manual effect hooks to achieve persistence. Additionally, Zustand's atomic selectors prevent unnecessary re-renders when unrelated store properties change.
Where is the API client configured for TanStack Query?
The API client logic resides in frontend/src/lib/api/ files such as notebooks.ts. These files export thin Axios wrappers (named like notebooksApi) that perform the actual HTTP requests. The TanStack Query hooks import these wrappers and pass them to queryFn parameters, keeping data fetching logic separate from caching and state management concerns.
Can Zustand and TanStack Query share state directly?
No, the two libraries maintain separate state containers. However, they interoperate through React's component lifecycle: components read Zustand values for immediate UI rendering (like themes) while TanStack Query manages asynchronous data. When a mutation succeeds, TanStack Query invalidates its cache, causing components to re-render with fresh server data while Zustand-managed UI preferences (like scroll position) remain unchanged.
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 →