Comprehensive Re-render Performance Report for Notion-Clone Kanban Board
Generated: January 4, 2026
Capture render counts and flicker symptom on refresh.
Refactor to selector hooks and slices; support sync hydration.
Re-measure renders; confirm flicker removal and smoother UX.
The migration from React Context to Zustand has fundamentally improved the application's rendering performance by addressing the core architectural flaw: cascading re-renders from context value changes.
The most visible improvement is the elimination of the dock (RunningTasksBar) flicker on page refresh. This single symptom was representative of a systemic issue with Context-based state management that affected the entire application.
When refreshing the root page with the dock in a collapsed state (saved to localStorage), users observed:
The flicker was caused by a race condition between React Context initialization and localStorage state restoration. With Context, the entire component tree re-rendered when the board data was hydrated, causing the dock to reset to its default state before the localStorage effect could run.
useReduceruseTasksByState only re-renders when relevant tasks change// Board.tsx - Synchronous hydration (the key fix)
const hydratedRef = useRef(false);
// Hydrate store synchronously on first render
if (!hydratedRef.current) {
useBoardStore.setState(board);
hydratedRef.current = true;
}
The Context-based architecture had multiple re-render issues across the application. Here is a comprehensive analysis:
Every dispatch to the reducer created a new context value object, even if the relevant data for a component hadn't changed.
// Old BoardContext.tsx pattern
const value = useMemo(() => ({
board, // New object every dispatch
patchTask,
createTask,
deleteTask,
// ... more actions
}), [board, /* all action deps */]);
Impact: Even with useMemo, the board object changed reference on every dispatch, causing all 10+ context consumers to re-render.
Any task change (even in an unrelated column) triggered re-renders of ALL columns.
| Scenario | Pre-Zustand Renders | Post-Zustand Renders |
|---|---|---|
| Edit task title in Column A | All 4 columns re-render | Only Column A re-renders |
| Move task from A to B | All 4 columns re-render | Columns A and B re-render |
| Open TaskPane | Board + all columns re-render | No board/column re-renders |
The Column.tsx component still has a TODO comment acknowledging this was a known issue:
/**
* TODO optimize the number of times it is rendered
*/
export default function Column({ column, onOpenTask }) { ... }
TaskPane maintained its own local paneTask state that could drift from the board context:
// OLD pattern (pre-migration)
const [paneTask, setPaneTask] = useState<TaskView | null>(task);
// This caused:
// 1. Stale data when board updated
// 2. Extra useState + useEffect re-renders
// 3. Manual sync required for every mutation
Migration Fix: Now derives task directly from store:
const paneTask = useActiveTask(taskId);
SubtaskList had its own items useState that was initialized from props but not synced:
// OLD pattern
const [items, setItems] = useState<TaskView[]>(initial || []);
// External updates = stale display
// Direct taskAdapter calls = bypassed context
Issues:
taskAdapter.update() calls bypassed contextThe dock consumed the entire context and filtered tasks locally:
// OLD pattern (hypothetical)
const { board } = useBoard();
const runningTasks = Object.values(board.tasks).filter(t => t.state === 'IN_PROGRESS');
const onHoldTasks = Object.values(board.tasks).filter(t => t.state === 'ON_HOLD');
// ... 4 more filters
// ANY task change = 6 filter recalculations + re-render
Now: Uses dedicated selector hooks that only update when their specific filter result changes:
const runningTasks = useTasksByState("IN_PROGRESS");
const onHoldTasks = useTasksByState("ON_HOLD");
// Each uses useShallow for array comparison
Moving a task triggered multiple dispatches and re-renders:
Result: 2x re-renders for a single drag-drop action, multiplied by all consumers.
The actions object (patchTask, moveTask, etc.) was part of the context value. Even though actions don't change, they were included in the context object that changed on every dispatch.
Zustand Fix: Actions are stable function references from the store, never change after creation:
// Actions are stable function refs in Zustand
export function useBoardActions() {
const hydrate = useBoardStore((s) => s.hydrate);
const patchTask = useBoardStore((s) => s.patchTask);
// ... returns memoized object
}
Context required a Provider component wrapping the tree, adding one extra component to the hierarchy. With Zustand, the store is a module-level singleton that doesn't need a Provider.
The core architectural shift: components now subscribe to specific slices of state rather than the entire context.
// useBoardStore.ts - Fine-grained selectors
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) {
return useBoardStore((s) => s.tasks[id]);
}
export function useTasksByState(state) {
return useBoardStore(
useShallow((s) => Object.values(s.tasks).filter((t) => t.state === state))
);
}
Zustand uses Object.is comparison by default. When a selector returns the same value as before, the component doesn't re-render. Combined with useShallow for arrays/objects, this provides surgical precision on when components update.
The crucial fix for the dock flicker:
// Board.tsx - Before first render
if (!hydratedRef.current) {
useBoardStore.setState(board); // Synchronous!
hydratedRef.current = true;
}
// vs old Context pattern
useEffect(() => {
dispatch({ type: "HYDRATE", data: board }); // Async!
}, []);
Zustand's useShallow hook performs shallow equality checks on arrays and objects:
export function useTasksInColumn(id) {
return useBoardStore(
useShallow((s) => {
const col = s.columns[id];
return col?.taskIds.map((tid) => s.tasks[tid]).filter(Boolean) ?? [];
})
);
}
Unlike Context where the actions object could change identity, Zustand actions are stable:
// Actions are defined once in create() and never change
const useBoardStore = create((set, get) => ({
hydrate: (data) => set(data),
patchTask: async (id, patch) => { ... },
// These function references never change
}));
The migration included Zustand DevTools middleware for debugging:
export const useBoardStore = create(
devtools(
(set, get) => ({ ... }),
{ name: "board-store" }
)
);
The Column.tsx component still has a TODO about optimizing renders. While Zustand improved the situation, the component could potentially benefit from:
TaskPane is a 1500+ line component with many useCallback/useMemo hooks. Potential issues:
Per the drift analysis doc, SubtaskList still uses taskAdapter.update() directly for some operations. While mitigated by refreshParent() calls, it's not fully unified.
| Component | Selector Used | Re-render Trigger |
|---|---|---|
| Board | useBoardColumns, useBoardStore(s => s.tasks) |
Only when columns/tasks map changes |
| Column | useTasksInColumn, useBoardId |
Only when that column's tasks change |
| TaskPane | useActiveTask, useSubtasks |
Only when the viewed task or its subtasks change |
| RunningTasksBar | useTasksByState (ร6 states) |
Only when tasks of that state change |
| TaskCard | Receives task as prop (memoizable) | When parent re-renders with different props |
As the board grows to hundreds of tasks, only affected components re-render. The selector-based architecture scales linearly rather than exponentially with component count.
With clear selector boundaries, performance is predictable. Adding new features won't unexpectedly degrade existing component performance.
Zustand DevTools show exactly which state changes occurred and when. Combined with React DevTools, identifying unnecessary re-renders is straightforward.
Store can be tested in isolation. Components can be tested with controlled store state injection.
As features grow, you may end up with dozens of selector hooks. Organization and naming conventions become important. Consider grouping selectors by feature domain.
Complex derived state (like useTasksByState) runs the filter on every render. For expensive computations, consider:
subscribeWithSelector middlewareIf you add more Zustand stores (e.g., user preferences, notifications), coordinating actions across stores requires careful attention to avoid update cascades.
Consider how Zustand will interact with server state libraries (React Query, SWR, etc.) if added later. The current pattern of upsertTask after API calls works well but could be formalized.
use[Entity]By[Filter] for consistency.