# How State Is Managed in the Kaneo Application: Zustand and React Query Architecture

> Discover how Kaneo manages state using Zustand for UI and React Query for server data. Learn about this efficient architecture for seamless application development.

- Repository: [kaneo.app/kaneo](https://github.com/usekaneo/kaneo)
- Tags: architecture
- Published: 2026-08-10

---

**Kaneo uses Zustand for client-side UI state (preferences, selections) and React Query for remote server data, creating a clear separation between ephemeral UI flags and persistent API entities.**

The open-source Kaneo project implements a dual-layer state management strategy that keeps the UI responsive while maintaining data consistency with the backend. By isolating local interface state from remote data fetching, the application avoids prop drilling and unnecessary re-renders while ensuring cached server data stays synchronized across components.

## Local UI State with Zustand

Kaneo leverages **Zustand** to handle all client-only state, including theme preferences, view modes, and temporary selection flags. These stores are lightweight, typed, and optionally persisted to `localStorage` for survival across page reloads.

### Persistent User Preferences

Global settings like theme and view mode live in [`apps/web/src/store/user-preferences.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/store/user-preferences.ts). The store uses Zustand's `persist` middleware to automatically serialize state to `localStorage`:

```typescript
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";

export const useUserPreferencesStore = create<UserPreferencesStore>()(
  persist(
    (set) => ({
      theme: "dark",
      setTheme: (theme) => set({ theme }),
      viewMode: "board",
      setViewMode: (mode) => set({ viewMode: mode }),
    }),
    {
      name: "user-preferences",
      storage: createJSONStorage(() => localStorage),
    },
  ),
);

```

The `persist` wrapper ensures that user choices survive browser refreshes, while the `UserPreferencesStore` type provides full TypeScript coverage for all fields and updaters.

### Temporary Selection State

For ephemeral UI state like bulk task selection, Kaneo uses non-persisted Zustand stores. The `useBulkSelectionStore` in [`apps/web/src/store/bulk-selection.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/store/bulk-selection.ts) manages selected task IDs without writing to storage:

```typescript
import { create } from "zustand";

interface BulkSelectionState {
  selectedTaskIds: Set<string>;
  isSelectMode: boolean;
  selectTask: (taskId: string) => void;
}

export const useBulkSelectionStore = create<BulkSelectionState>((set, get) => ({
  selectedTaskIds: new Set(),
  isSelectMode: false,
  selectTask: (taskId) =>
    set((state) => ({
      selectedTaskIds: new Set([...state.selectedTaskIds, taskId]),
      isSelectMode: true,
    })),
}));

```

Components access this state via selectors to prevent unnecessary re-renders:

```tsx
const { selectTask, selectedTaskIds } = useBulkSelectionStore();
const theme = useUserPreferencesStore((s) => s.theme);

```

### Project Context State

The current active project is maintained in [`apps/web/src/store/project.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/store/project.ts). This store holds the currently loaded project ID and metadata, serving as the source of truth for project-scoped components without requiring prop drilling from parent routes.

## Remote Data with React Query

All server-side entities—projects, tasks, users, and workspaces—are managed by **TanStack Query** (React Query). This layer handles caching, background refetching, and optimistic updates automatically.

### Querying Server Data

Data fetching hooks follow a consistent pattern in `apps/web/src/hooks/queries/`. For example, [`use-get-full-workspace.ts`](https://github.com/usekaneo/kaneo/blob/main/use-get-full-workspace.ts) fetches workspace details with automatic caching:

```typescript
import { useQuery } from "@tanstack/react-query";
import { getFullWorkspace } from "@/fetchers/workspace/get-full-workspace";

export function useFullWorkspace(workspaceId: string) {
  return useQuery({
    queryKey: ["workspace", workspaceId],
    queryFn: () => getFullWorkspace(workspaceId),
  });
}

```

The `queryKey` array ensures that identical requests share cached results across components, eliminating redundant network calls.

### Mutations and Cache Invalidation

Update operations reside in `apps/web/src/hooks/mutations/` and explicitly invalidate related queries upon success. The [`use-update-task.ts`](https://github.com/usekaneo/kaneo/blob/main/use-update-task.ts) hook demonstrates this pattern:

```typescript
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { updateTask } from "@/fetchers/task/update-task";

export function useUpdateTask() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: updateTask,
    onSuccess: () => queryClient.invalidateQueries({ queryKey: ["tasks"] }),
  });
}

```

When a mutation succeeds, `invalidateQueries` forces React Query to refetch the affected data, ensuring the UI remains synchronized with the server state.

## Architecture Benefits and Interaction Patterns

The separation between Zustand and React Query creates clear ownership boundaries. **Zustand** manages UI chrome—theme toggles, sidebar state, and bulk selection checkboxes—while **React Query** owns all API-derived data.

When a user updates a task priority, the flow demonstrates this interaction:

1. The component calls `useUpdateTask().mutate()` to trigger the React Query mutation
2. On success, `queryClient.invalidateQueries(["task", taskId])` executes
3. React Query automatically refetches the task data
4. Components subscribed to that queryKey re-render with fresh data
5. Zustand stores remain unaffected unless the UI needs to clear selection state

This pattern prevents stale data without requiring manual cache manipulation in Zustand stores.

## Summary

- **Zustand** handles client-only state in `apps/web/src/store/` for themes, view modes, and temporary selections like bulk task operations.
- **React Query** manages all server data in `apps/web/src/hooks/queries/` and `apps/web/src/hooks/mutations/`, providing automatic caching and synchronization.
- **Persistence** is implemented via Zustand's `persist` middleware for user preferences, writing to `localStorage` with keys like `user-preferences`.
- **Cache invalidation** occurs through React Query's `queryClient.invalidateQueries()`, ensuring mutations trigger automatic refetching of affected data.

## Frequently Asked Questions

### Why does Kaneo use Zustand instead of React Context?

Zustand provides better performance characteristics for high-frequency updates and eliminates the need for provider wrapping. According to the Kaneo source code, Zustand stores like `useBulkSelectionStore` and `useUserPreferencesStore` can be imported directly into components without prop drilling or context consumers, resulting in fewer re-renders when unrelated state changes.

### How does Kaneo persist user preferences across sessions?

The `useUserPreferencesStore` in [`apps/web/src/store/user-preferences.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/store/user-preferences.ts) wraps its state with the `persist` middleware from `zustand/middleware`. This configuration uses `createJSONStorage(() => localStorage)` to serialize the store to `localStorage` under the key `user-preferences`, ensuring theme and view mode selections survive page reloads.

### What happens when a mutation succeeds in Kaneo?

Successful mutations trigger cache invalidation via `queryClient.invalidateQueries()` within the mutation's `onSuccess` callback. For example, when `useUpdateTask` completes, it invalidates the `tasks` query key, causing React Query to automatically refetch that data and update all subscribed components with the new server state.

### Where are the React Query hooks located in the Kaneo codebase?

Query hooks reside in `apps/web/src/hooks/queries/` organized by entity (e.g., [`workspace/use-get-full-workspace.ts`](https://github.com/usekaneo/kaneo/blob/main/workspace/use-get-full-workspace.ts)), while mutation hooks live in `apps/web/src/hooks/mutations/`. The global `QueryClient` configuration is defined in [`apps/web/src/query-client/index.ts`](https://github.com/usekaneo/kaneo/blob/main/apps/web/src/query-client/index.ts) and provides the default caching and retry policies used throughout the application.