Step 5 — Local & Global State with Zustand

Not all state belongs in a server response. "Is this dialog open?", "which files are mid-delete?", "is this task's agent currently stopping?" — that's client-only UI state, and iKanban reaches for Zustand when that state needs to be shared across components that aren't parent/child. Let's read the real store: src/stores/useTaskDetailsUiStore.ts.

The problem it solves

A task detail panel and a task list row might both need to know "is this task's delete-in-progress spinner showing?" — but they're not nested inside each other. Passing that state down through props ("prop drilling") gets awkward fast. Zustand gives every component that calls the store's hook a shared, reactive slice of state, without a Context Provider wrapping your tree.

Anatomy of a Zustand store

interface TaskUiState {
  loading: boolean;
  isStopping: boolean;
  deletingFiles: Set<string>;
  fileToDelete: string | null;
}

interface UiStateMap {
  [taskId: string]: TaskUiState;
}

interface TaskDetailsUiStore {
  ui: UiStateMap;
  getUiState: (taskId: string) => TaskUiState;
  setUiState: (taskId: string, partial: Partial<TaskUiState>) => void;
  clearUiState: (taskId: string) => void;
}

const useTaskDetailsUiStore = create<TaskDetailsUiStore>((set, get) => ({
  ui: {},
  getUiState: (taskId) => get().ui[taskId] ?? defaultUiState,
  setUiState: (taskId, partial) => {
    set((state) => ({
      ui: { ...state.ui, [taskId]: { ...defaultUiState, ...state.ui[taskId], ...partial } },
    }));
  },
  clearUiState: (taskId) => {
    set((state) => {
      const newUi = { ...state.ui };
      delete newUi[taskId];
      return { ui: newUi };
    });
  },
}));

Notice the shape: this store is keyed by taskId, not a single flat state object. iKanban can have many task detail panels' worth of UI state alive at once (e.g. across browser tabs or a multi-panel layout), so UiStateMap gives each task its own isolated TaskUiState slice.

create<TaskDetailsUiStore>((set, get) => ({ ... })) is the entire API surface: set merges a partial update into state (triggering re-renders in subscribed components), get reads current state without subscribing. There's no reducer, no action types, no dispatch — just a plain object of state and functions that update it.

A derived, narrow hook

export const useTaskStopping = (taskId: string) => {
  const { getUiState, setUiState } = useTaskDetailsUiStore();
  const { isStopping } = getUiState(taskId);

  return {
    isStopping,
    setIsStopping: (value: boolean) => setUiState(taskId, { isStopping: value }),
  };
};

Rather than have every consumer call useTaskDetailsUiStore() and manually pull out isStopping, the store file exports purpose-built hooks like useTaskStopping. A component that only cares whether the agent is stopping imports this one hook and gets exactly { isStopping, setIsStopping } — nothing else, and no risk of accidentally reading or mutating unrelated UI state.

When to reach for Zustand vs. plain useState

Checkpoint

In useTaskDetailsUiStore.ts, find the deletingFiles: Set<string> field and see how setUiState handles it specially (hint: Set isn't a plain object, so a shallow spread { ...old } wouldn't create a new Set — read the deletingFiles: partial.deletingFiles ? new Set(...) : ... line).

Continue to Step 6 — Routing with react-router-dom + Data Fetching with TanStack Query, where we bring in data that comes from the network instead of the client.