rview
The zero-backend Kanban board utilizes a unidirectional data flow with client-side persistence:
- State Management: Zustand for lightweight, immutable state with middleware support.
- Persistence Layer:
idb (IndexedDB wrapper) for async, transactional storage.
- Sync Mechanism:
BroadcastChannel for cross-tab state diffusion.
- UI Pattern: Optimistic updates with automatic rollback on persistence failure.
Implementation Details
1. Zustand Store with IndexedDB Persistence
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';
import { openDB, DBSchema, IDBPDatabase } from 'idb';
interface KanbanState {
columns: Record<string, string[]>;
cards: Record<string, { id: string; title: string; columnId: string }>;
addCard: (columnId: string, title: string) => void;
moveCard: (cardId: string, fromColumn: string, toColumn: string) => void;
}
const dbPromise = openDB<KanbanState>('kanban-db', 1, {
upgrade(db) {
db.createObjectStore('state');
},
});
const idbStorage = {
getItem: async (name: string) => {
const db = await dbPromise;
return db.get('state', name) || null;
},
setItem: async (name: string, value: string) => {
const db = await dbPromise;
await db.put('state', value, name);
},
removeItem: async (name: string) => {
const db = await dbPromise;
await db.delete('state', name);
},
};
export const useKanbanStore = create<KanbanState>()(
persist(
(set) => ({
columns: { 'todo': [], 'in-progress': [], 'done': [] },
cards: {},
addCard: (columnId, title) => set((state) => ({
columns: {
...state.columns,
[columnId]: [...state.columns[columnId], crypto.randomUUID()]
},
cards: {
...state.cards,
[crypto.randomUUID()]: { id: crypto.randomUUID(), title, columnId }
}
})),
moveCard: (cardId, fromColumn, toColumn) => set((state) => ({
columns: {
...state.columns,
[fromColumn]: state.columns[fromColumn].filter(id => id !== cardId),
[toColumn]: [...state.columns[toColumn], cardId]
},
cards: {
...state.cards,
[cardId]: { ...state.cards[cardId], columnId: toColumn }
}
}))
}),
{ name: 'kanban-storage', storage: createJSONStorage(() => idbStorage) }
)
);
2. Cross-Tab Synchronization via BroadcastChannel
import { useEffect } from 'react';
import { useKanbanStore } from './store';
const SYNC_CHANNEL = 'kanban-sync';
export function useSyncAcrossTabs() {
const state = useKanbanStore();
useEffect(() => {
const channel = new BroadcastChannel(SYNC_CHANNEL);
channel.onmessage = (event) => {
if (event.data.type === 'STATE_UPDATE') {
useKanbanStore.setState(event.data.payload, true);
}
};
return () => channel.close();
}, []);
useEffect(() => {
const channel = new BroadcastChannel(SYNC_CHANNEL);
const unsubscribe = useKanbanStore.subscribe((newState) => {
channel.postMessage({ type: 'STATE_UPDATE', payload: newState });
});
return () => {
unsubscribe();
channel.close();
};
}, []);
}
3. Optimistic Drag-and-Drop Handler
import { useDraggable } from '@dnd-kit/core';
import { useKanbanStore } from './store';
export function DraggableCard({ cardId }: { cardId: string }) {
const { attributes, listeners, setNodeRef, transform } = useDraggable({ id: cardId });
const moveCard = useKanbanStore((s) => s.moveCard);
const handleDragEnd = async (event: any) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const fromColumn = useKanbanStore.getState().cards[active.id].columnId;
const toColumn = over.id as string;
// Optimistic update
moveCard(active.id, fromColumn, toColumn);
try {
// Persist to IndexedDB (handled automatically by Zustand persist middleware)
await new Promise(resolve => setTimeout(resolve, 0)); // Yield to event loop
} catch (error) {
// Rollback on failure
moveCard(active.id, toColumn, fromColumn);
console.error('Persistence failed, rolled back:', error);
}
};
return (
<div ref={setNodeRef} {...listeners} {...attributes} style={{ transform: `translate(${transform?.x ?? 0}px, ${transform?.y ?? 0}px)` }}>
{/* Card UI */}
</div>
);
}
Pitfall Guide
- Blocking the Main Thread with Synchronous Storage: Using
localStorage for frequent drag-and-drop operations blocks the event loop. IndexedDB's asynchronous API prevents UI jank, but improper promise chaining can still cause microtask queue congestion. Always batch writes or debounce rapid state changes.
- Race Conditions in Optimistic Updates: Applying UI changes before persistence completes can lead to divergent state if the write fails or another tab syncs concurrently. Implement explicit rollback handlers and versioned state snapshots to guarantee consistency.
- Memory Leaks from BroadcastChannel Listeners: Failing to close
BroadcastChannel instances or unsubscribe from Zustand stores on component unmount causes duplicate message broadcasts and memory accumulation. Always pair useEffect cleanup functions with channel closure and subscription teardown.
- Ignoring IndexedDB Transaction Limits: IndexedDB enforces strict transaction boundaries. Attempting to read/write across multiple object stores without a single transaction context throws
InvalidStateError. Structure your schema to minimize cross-store dependencies or use explicit transaction scopes.
- Over-Syncing State Payloads: Broadcasting the entire Zustand state on every minor change saturates the
BroadcastChannel message queue and degrades performance in multi-tab scenarios. Diff state changes and only broadcast delta payloads or use a generation counter to skip redundant syncs.
- Assuming Storage Quota Guarantees: Browsers enforce storage limits (typically 50MBβ2GB depending on origin and user settings). Large Kanban boards with rich metadata can silently fail persistence. Implement quota monitoring via
navigator.storage.estimate() and graceful degradation strategies.
- Missing Hydration State Management: Zustand's
persist middleware hydrates asynchronously. Rendering components before hydration completes causes UI flicker or incorrect initial state. Use useStore.persist.hasHydrated() to gate rendering or display a skeleton loader until state is ready.
Deliverables
- π Zero-Backend Kanban Blueprint: Complete architecture diagram detailing state flow, persistence layer, sync topology, and error boundaries. Includes schema definitions for IndexedDB and Zustand middleware configuration.
- β
Implementation Checklist: 24-point verification list covering storage initialization, optimistic update safety, cross-tab sync validation, quota monitoring, and performance profiling steps.
- βοΈ Configuration Templates: Ready-to-use
vite.config.ts for optimized bundling, tsconfig.json strict mode presets, Zustand persist middleware setup, and idb schema migration scripts. Includes environment-specific overrides for development vs. production storage strategies.