BoardContext → Zustand Migration
A comprehensive migration from React Context + useReducer to Zustand for improved performance, developer experience, and simplified state management.
Problem
- React Context + useReducer caused broad re-renders.
- State spread across providers made additions noisy.
- DX friction for selectors, testing, and composability.
Solution
- Introduce a centralized Zustand store for board state.
- Use selector hooks to scope subscriptions and cut renders.
- Type-safe actions, devtools support, and no provider boilerplate.
Impact
- Cleaner components with targeted subscriptions.
- Lower re-render churn; faster UI response.
- Simpler DX for future features and tests.
Map context state/actions; scaffold Zustand store and types.
Replace context usage with selector hooks to isolate updates.
Remove provider, run tests, and confirm render improvements.
- State changes stay local; UI feels snappier.
- Developers add features without wrestling providers.
- Foundation ready for future board features.
📋 Migration Checklist
- ✓Create implementation plan
Analyzed existing architecture, identified migration strategy - ✓Install Zustand and create store
Added zustand@5.0.9, created useBoardStore.ts - ✓Migrate reducer logic to Zustand actions
Converted PATCH_TASK, MOVE_TASK, UPSERT_TASK to store actions - ✓Create selector hooks with shallow comparison
Implemented useShallow for array-returning selectors - ✓Update components to use Zustand store
Migrated 8 components to use new hooks - ✓Remove BoardProvider and update tests
Eliminated Context wrapper, updated test setup - ✓Verify functionality
All tests pass, build compiles successfully
📁 Files Changed Summary
💡 Why Zustand?
Problems with React Context
1. Re-render Cascades
With React Context, when any part of the board state changes, every component subscribed to the context re-renders, even if their specific data didn't change.
2. Selector Instability
The useBoardSelector pattern required careful memoization. Creating new function references caused unnecessary re-renders.
3. Provider Boilerplate
Every component needed to be wrapped in BoardProvider, and hooks required null checks for context access.
4. No DevTools
Debugging state changes was difficult without built-in time-travel debugging or action logging.
Benefits of Zustand
| Aspect | Before (Context) | After (Zustand) |
|---|---|---|
| Re-renders | Full subtree on any change | Granular, per-selector subscriptions |
| Provider | Required wrapper component | No provider needed |
| Null Checks | Every hook call | Direct store access |
| DevTools | None | Redux DevTools integration |
| Access Outside React | Not possible | getState() anywhere |
| Bundle Size | React Context (built-in) | +3KB (zustand) |
🏗️ Phase 1: Create Zustand Store
Created src/store/useBoardStore.ts - the central Zustand store combining state, actions, and selectors.
Store Structure
The store combines the existing reducer logic with async actions in a single, cohesive unit:
"use client";
import { create } from "zustand";
import { devtools } from "zustand/middleware";
import { useShallow } from "zustand/react/shallow";
import { BoardDTO, TaskPatch } from "@/types/data";
import { TaskCreatePayload, TaskView } from "@/types/taskView";
import { boardService } from "@/services/boardService";
import { pulseColumn, toastError } from "@/lib/uiEffects";
type BoardActions = {
hydrate: (data: BoardDTO) => void;
patchTask: (id: string, patch: TaskPatch) => Promise<TaskView>;
moveTask: (id: string, toColumnId: string) => Promise<void>;
upsertTask: (task: TaskView) => void;
createTask: (payload: TaskCreatePayload) => Promise<TaskView>;
createSubtask: (parentId: string, title: string) => Promise<TaskView>;
reopenTask: (id: string, reason: string) => Promise<TaskView>;
};
type BoardStore = BoardDTO & BoardActions;
const initialState: BoardDTO = {
id: "",
name: "",
columns: {},
tasks: {},
};
export const useBoardStore = create<BoardStore>()(
devtools(
(set, get) => ({
...initialState,
hydrate: (data) => set(data, false, "hydrate"),
// ... actions implementation
}),
{ name: "board-store" }
)
);
Key Actions
upsertTask
Handles adding new tasks and updating existing ones, including column membership logic:
upsertTask: (task: TaskView) => {
const state = get();
const existing = state.tasks[task.id];
const nextTasks = { ...state.tasks, [task.id]: task };
// Subtasks don't belong in columns
const isSubtask = (task.parentTaskIds?.length ?? 0) > 0;
if (isSubtask) {
set({ tasks: nextTasks }, false, "upsertTask/subtask");
return;
}
// Handle column membership for new tasks
if (!existing) {
const col = state.columns[task.columnId || ""];
if (col) {
set({
tasks: nextTasks,
columns: {
...state.columns,
[col.id]: { ...col, taskIds: [...new Set([...col.taskIds, task.id])] },
},
}, false, "upsertTask/new");
}
return;
}
// Handle column changes for existing tasks
if (task.columnId && task.columnId !== existing.columnId) {
// Move between columns...
}
set({ tasks: nextTasks }, false, "upsertTask/update");
}
moveTask (with Optimistic Updates)
Implements optimistic UI updates with rollback on failure:
moveTask: async (id: string, toColumnId: string) => {
const state = get();
const task = state.tasks[id];
const prevColumnId = task.columnId;
// Optimistic update
set({
tasks: { ...state.tasks, [id]: { ...task, columnId: toColumnId } },
columns: { /* update column taskIds */ },
}, false, "moveTask/optimistic");
try {
const updated = await boardService.moveTask(id, toColumnId);
get().upsertTask(updated);
pulseColumn(toColumnId);
} catch (err) {
// Rollback on failure
set({ /* restore previous state */ }, false, "moveTask/rollback");
toastError("Failed to move task");
throw err;
}
}
🔗 Phase 2: Selector Hooks
The Re-render Problem
Zustand selectors that return arrays or objects create new references on every call, causing infinite re-renders:
❌ Problematic
// Creates new array every render
export function useTasksInColumn(id) {
return useBoardStore((s) => {
const col = s.columns[id];
return col?.taskIds.map(
(tid) => s.tasks[tid]
) ?? [];
});
}
✅ Solution: useShallow
// Shallow comparison prevents loops
export function useTasksInColumn(id) {
return useBoardStore(
useShallow((s) => {
const col = s.columns[id];
return col?.taskIds.map(
(tid) => s.tasks[tid]
) ?? [];
})
);
}
All selectors that return arrays or objects MUST use useShallow wrapper. This applies to: useTasksInColumn, useSubtasks, useTasksByState
Complete Selector List
// Primitive selectors (no useShallow needed)
export const useBoardId = () => useBoardStore((s) => s.id);
export const useBoardName = () => useBoardStore((s) => s.name);
export const useBoardTasks = () => useBoardStore((s) => s.tasks);
export const useBoardColumns = () => useBoardStore((s) => s.columns);
export function useTaskById(id: string) {
return useBoardStore((s) => s.tasks[id]);
}
export function useActiveTask(id: string | null) {
return useBoardStore((s) => (id ? s.tasks[id] ?? null : null));
}
// Array selectors (useShallow required)
export function useTasksInColumn(id: string) {
return useBoardStore(
useShallow((s) => {
const col = s.columns[id];
return col?.taskIds.map((tid) => s.tasks[tid]).filter(Boolean) ?? [];
})
);
}
export function useSubtasks(parentId: string) {
return useBoardStore(
useShallow((s) =>
Object.values(s.tasks).filter((t) => t.parentTaskIds?.includes(parentId))
)
);
}
export function useTasksByState(state: TaskState) {
return useBoardStore(
useShallow((s) => Object.values(s.tasks).filter((t) => t.state === state))
);
}
🔄 Phase 3: Component Updates
Import Migration
All components were updated to import from the new boardHooks.ts (renamed from BoardContext.tsx):
❌ Before
import {
useBoardTasks,
useBoardActionsCtx
} from "./BoardContext";
✅ After
import {
useBoardTasks,
useBoardActionsCtx
} from "./boardHooks";
Components Updated
| Component | Hooks Used | Changes |
|---|---|---|
| Column.tsx | useBoardId, useBoardActionsCtx, useTasksInColumn | Import path only |
| TaskCard.tsx | useSubtasks | Import path only |
| TaskPane.tsx | useBoardTasks, useBoardColumns, useBoardActionsCtx, useActiveTask, useSubtasks | Import path only |
| SubtaskList.tsx | useBoardTasks, useBoardColumns, useBoardActionsCtx, useSubtasks | Import path only |
| GlobalTaskSearch.tsx | useBoardTasks | Import path only |
| CommandPalette.tsx | useBoardTasks | Import path only |
| RunningTasksBar.tsx | useBoardTasks, useTasksByState | Import path only |
By re-exporting all hooks from boardHooks.ts, component changes were minimal - just import path updates.
🗑️ Phase 4: Remove BoardProvider
The Key Change
With Zustand, we no longer need a React Context provider. The store is a module-level singleton that components subscribe to directly.
Board.tsx - Store Hydration
❌ Before: Provider Wrapper
export default function Board({ board }) {
return (
<BoardProvider initial={board}>
<BoardDndShell
board={board}
onMoveTask={...}>
{/* children */}
</BoardDndShell>
</BoardProvider>
);
}
✅ After: Direct Hydration
export default function Board({ board }) {
const hydratedRef = useRef(false);
// Hydrate store synchronously
if (!hydratedRef.current) {
useBoardStore.setState(board);
hydratedRef.current = true;
}
return (
<BoardDndShell>
{/* children */}
</BoardDndShell>
);
}
BoardDndShell.tsx - Direct Subscription
❌ Before: Props from Provider
type Props = {
board: BoardDTO;
onMoveTask: (id, col) => void;
children: ReactNode;
};
export function BoardDndShell({
board,
onMoveTask,
children
}: Props) {
// Use props directly
const task = board.tasks[id];
}
✅ After: Subscribe to Store
type Props = {
children: ReactNode;
};
export function BoardDndShell({
children
}: Props) {
// Subscribe to store directly
const tasks = useBoardStore(s => s.tasks);
const moveTask = useBoardStore(s => s.moveTask);
}
Test Updates
Tests were updated to use useBoardStore.setState() instead of wrapping in BoardProvider:
import { useBoardStore, initialState } from "../../src/store/useBoardStore";
describe("Board Column", () => {
beforeEach(() => {
// Reset store between tests
useBoardStore.setState(initialState);
});
it("renders column name and tasks", () => {
const board = { /* test data */ };
// Hydrate store directly instead of using BoardProvider
useBoardStore.setState(board);
render(<Column column={column} onOpenTask={vi.fn()} />);
expect(screen.getByText("To Do")).toBeInTheDocument();
});
});
🏛️ Final Architecture
Component Hierarchy
Data Flow
The entire component tree now operates without any React Context providers. Components subscribe to exactly the state slices they need.
✅ Verification
Test Results
| Test Suite | Tests | Status |
|---|---|---|
| Board.spec.tsx | 2 | ✅ Passed |
| SubtaskList.spec.tsx | 2 | ✅ Passed |
| TaskCard.spec.tsx | 4 | ✅ Passed |
| DragAndDrop.spec.tsx | 1 | ✅ Passed |
| BoardTitleSync.spec.tsx | 2 | ✅ Passed |
Build Status
TypeScript compilation passed with no new errors
Manual Testing Checklist
- Board loads correctly with all columns and tasks
- Drag-and-drop moves tasks between columns
- Task editing (title, state, priority) persists
- Subtask operations (add parent, remove parent)
- Command palette search works (Cmd+K)
- Global task search filters correctly
- Running tasks bar updates in real-time
📁 Complete File Changes
New Files
NEW src/store/useBoardStore.ts
Central Zustand store with state, actions, and selector hooks. ~290 lines.
Modified Files
MODIFY src/components/boardHooks.ts
Renamed from BoardContext.tsx. Now re-exports Zustand hooks for backward compatibility. Removed BoardProvider.
MODIFY src/components/Board.tsx
Hydrates store directly using useBoardStore.setState() with useRef guard. Removed BoardProvider wrapper.
MODIFY src/components/BoardDndShell.tsx
Subscribes to Zustand store directly. Removed board and onMoveTask props.
MODIFY Component Imports (7 files)
Updated import paths from ./BoardContext to ./boardHooks:
- Column.tsx
- TaskCard.tsx
- TaskPane.tsx
- SubtaskList.tsx
- GlobalTaskSearch.tsx
- CommandPalette.tsx
- RunningTasksBar.tsx
MODIFY tests/setup.ts
Added beforeEach hook to reset Zustand store between tests.
MODIFY Test Files
Updated to use useBoardStore.setState() instead of BoardProvider wrapper:
- tests/components/Board.spec.tsx
- tests/components/SubtaskList.spec.tsx
- tests/components/DragAndDrop.spec.tsx
- tests/components/TaskPane.spec.tsx
- tests/components/BoardTitleSync.spec.tsx
- __tests__/boardContextSelectors.test.tsx