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

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. The store uses Zustand's persist middleware to automatically serialize state to localStorage:

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 manages selected task IDs without writing to storage:

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:

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. 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 fetches workspace details with automatic caching:

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 hook demonstrates this pattern:

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 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), while mutation hooks live in apps/web/src/hooks/mutations/. The global QueryClient configuration is defined in apps/web/src/query-client/index.ts and provides the default caching and retry policies used throughout the application.

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 →