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
- Plain
useState: state used by one component and its direct children — pass it down as props. - Zustand: state needed by components that don't share a close parent, or that must persist across a component unmounting/remounting (e.g. switching away from a task panel and back).
- Neither — for state that comes from the server (a list of tags, a task's details): that's Step 6's job, TanStack Query, not Zustand. iKanban keeps a firm line between "UI state" (Zustand) and "server state" (TanStack Query) rather than stuffing API responses into a Zustand store.
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.