# How Zustand Stores and TanStack Query Coordinate Frontend State Management in Open Notebook

> Learn how Open Notebook coordinates Zustand stores and TanStack Query for frontend state management. Discover how they sync client UI state with server data using query invalidation and side effects.

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

---

**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 `localStorage` or `sessionStorage`.

- **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 `QueryClient` singleton.

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`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/lib/api/query-client.ts) to guarantee a unified cache across the application:

```typescript
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`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/lib/stores/notebook-view-store.ts), the view mode persists across reloads:

```typescript
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`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/lib/stores/navigation-store.ts) manages breadcrumb history, and [`frontend/src/lib/stores/auth-store.ts`](https://github.com/lfnovo/open-notebook/blob/main/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:

```tsx
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`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/components/source/SourceDetailContent.tsx), the component reads server data via `useQuery` while updating navigation state in Zustand:

```tsx
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`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/components/layout/CollapsibleColumn.tsx) uses the notebook view store to toggle between layouts while fetching data through TanStack Query:

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

```tsx
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 `QueryClient` in [`frontend/src/lib/api/query-client.ts`](https://github.com/lfnovo/open-notebook/blob/main/frontend/src/lib/api/query-client.ts), caching notebooks, sources, and notes.
- **Cache invalidation** occurs via `queryClient.invalidateQueries()` inside mutation `onSuccess` callbacks, ensuring server changes reflect immediately in the UI.
- **Component coordination** happens when hooks like `useQuery` provide server data while Zustand selectors provide UI preferences, with `useEffect` bridging 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`](https://github.com/lfnovo/open-notebook/blob/main/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.