# How the Open Notebook React Frontend Is Structured with Zustand State Management and TanStack Query

> Explore the Open Notebook React frontend structure using Zustand for UI state and TanStack Query for server state. Learn how these are integrated in a Next.js app.

- Repository: [Luis Novo/open-notebook](https://github.com/lfnovo/open-notebook)
- Tags: architecture
- Published: 2026-06-21

---

**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:

```ts
// 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`](https://github.com/lfnovo/open-notebook/blob/main/sidebar-store.ts) for UI layout state and [`auth-store.ts`](https://github.com/lfnovo/open-notebook/blob/main/auth-store.ts) for authentication tokens.

### Consuming Stores in Components

Components import the hook and select specific slices to optimize re-renders:

```tsx
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`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/lib/api/query-client.ts) defines default cache behavior:

```ts
// 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:

```tsx
// 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:

```tsx
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:

```tsx
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`](https://github.com/lfnovo/open-notebook/blob/main/SourceDetailContent.tsx) and [`GeneratePodcastDialog.tsx`](https://github.com/lfnovo/open-notebook/blob/main/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`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/app/layout.tsx) composes providers in a specific order:

```tsx
<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 `QueryClient` configured in [`frontend/src/lib/api/query-client.ts`](https://github.com/lfnovo/open-notebook/blob/main/frontend/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.tsx`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/app/layout.tsx) layers context from global (theme) to specific (modals), ensuring predictable state access.
- **Real components** like [`SourceDetailContent.tsx`](https://github.com/lfnovo/open-notebook/blob/main/SourceDetailContent.tsx) demonstrate 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`](https://github.com/lfnovo/open-notebook/blob/main/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.