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:

  1. Zustand (Client State) – Handles synchronous, browser-specific UI concerns such as theme selection, sidebar collapse states, and navigation shortcuts. These stores persist to localStorage using the persist middleware.

  2. 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:

  1. useNotebooks triggers a GET request to /notebooks via notebooksApi.list() and caches the result under QUERY_KEYS.notebooks
  2. useTheme reads the current theme from the Zustand store synchronously
  3. When the user toggles the theme, the Zustand store updates instantly—no network request occurs
  4. When a mutation elsewhere creates a notebook, invalidateQueries triggers a background refetch, and NotebookList re-renders with fresh data

Summary

  • Zustand handles synchronous UI state (themes, navigation, sidebar) and persists it to localStorage via the persist middleware
  • TanStack Query manages asynchronous server state with automatic caching, background refetching, and query deduplication
  • Cache invalidation occurs through queryClient.invalidateQueries() in mutation onSuccess callbacks, ensuring UI consistency
  • Centralized query keys in query-client.ts prevent typos and ensure consistent cache management across the application
  • Provider composition in the root layout wraps the app with both QueryClientProvider and 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:

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 →