Skip to main content

Kanban Persistence: Save State to LocalStorage

Persisting a kanban board to localStorage ensures users don't lose work when they close the browser. Writing to localStorage on every drag is wasteful; batching writes with debouncing or auto-save intervals improves performance. A production kanban app saves state every 5-30 seconds or on explicit "save" actions, with transparent conflict resolution if the user edits from multiple tabs.

Basics: Writing to and Reading from localStorage

The browser's localStorage API is a simple key-value store (5-10 MB per origin). Save your board state as JSON:

function Board() {
const [tasks, setTasks] = React.useState(() => {
// Read from localStorage on mount
const saved = localStorage.getItem('kanban-tasks');
return saved ? JSON.parse(saved) : initialTasks;
});

function moveTask(taskId, fromColumn, toColumn) {
setTasks((prev) => {
const updated = { /* ... move logic ... */ };
// Save to localStorage on update
localStorage.setItem('kanban-tasks', JSON.stringify(updated));
return updated;
});
}

return <div className="board">{/* ... */}</div>;
}

This reads tasks from localStorage on mount using lazy initialization (the function passed to useState). Every moveTask call writes the updated state back to localStorage. The drawback: writing on every drag can cause 60+ writes per second, exhausting write quota and blocking the UI briefly on large boards.

Debouncing Writes for Better Performance

Instead of writing immediately, queue updates and flush them at intervals:

function Board() {
const [tasks, setTasks] = React.useState(() => {
const saved = localStorage.getItem('kanban-tasks');
return saved ? JSON.parse(saved) : initialTasks;
});

const [isDirty, setIsDirty] = React.useState(false);
const timeoutRef = React.useRef(null);

function moveTask(taskId, fromColumn, toColumn) {
setTasks((prev) => {
const updated = { /* ... move logic ... */ };
setIsDirty(true); // Mark as needing save

// Clear previous timeout
if (timeoutRef.current) clearTimeout(timeoutRef.current);

// Debounce write: wait 2 seconds before saving
timeoutRef.current = setTimeout(() => {
localStorage.setItem('kanban-tasks', JSON.stringify(updated));
setIsDirty(false);
}, 2000);

return updated;
});
}

React.useEffect(() => {
return () => {
// On unmount, save if dirty
if (isDirty && timeoutRef.current) {
clearTimeout(timeoutRef.current);
localStorage.setItem('kanban-tasks', JSON.stringify(tasks));
}
};
}, [isDirty, tasks]);

return (
<div className="board">
{isDirty && <p className="saving-indicator">Saving...</p>}
{/* ... board content ... */}
</div>
);
}

This approach batches writes: moving 5 cards in quick succession results in 1 write (at 2 seconds), not 5. The isDirty flag tracks whether unsaved changes exist. On unmount, the cleanup function saves if needed, preventing data loss if the user closes the tab.

Handling Multi-Tab Conflicts

If the user opens your board in two browser tabs, both read the same localStorage entry. When one tab saves, the other's in-memory state becomes stale. Detect this with the storage event:

function Board() {
const [tasks, setTasks] = React.useState(() => {
const saved = localStorage.getItem('kanban-tasks');
return saved ? JSON.parse(saved) : initialTasks;
});

React.useEffect(() => {
function handleStorageChange(event) {
if (event.key === 'kanban-tasks' && event.newValue) {
const remoteTasks = JSON.parse(event.newValue);
// Conflict resolution: prefer remote (latest) version
setTasks(remoteTasks);
}
}

window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, []);

// ... rest of component
}

The storage event fires in all OTHER tabs when localStorage changes. Listen for it and update your local state. This keeps tabs synchronized. Conflict resolution here is simple (prefer the remote version); for more sophisticated conflicts, implement three-way merge or vector clocks (covered in the real-time sync article).

Compressing Large State

For a kanban with thousands of tasks, JSON serialization can produce a 1+ MB string, exceeding localStorage quota. Compress before storing:

npm install lz-string
import LZ from 'lz-string';

function Board() {
const [tasks, setTasks] = React.useState(() => {
const compressed = localStorage.getItem('kanban-tasks');
if (!compressed) return initialTasks;
try {
const json = LZ.decompressFromUTF16(compressed);
return JSON.parse(json);
} catch (e) {
console.error('Failed to decompress tasks:', e);
return initialTasks;
}
});

function saveToStorage() {
try {
const json = JSON.stringify(tasks);
const compressed = LZ.compressToUTF16(json);
localStorage.setItem('kanban-tasks', compressed);
} catch (e) {
console.error('Storage error:', e);
alert('Could not save; localStorage may be full.');
}
}

React.useEffect(() => {
const timeout = setTimeout(saveToStorage, 2000);
return () => clearTimeout(timeout);
}, [tasks]);

return <div className="board">{/* ... */}</div>;
}

Compression via lz-string can reduce a 500 KB state to 50-80 KB, fitting comfortably in localStorage. Trade-off: compression/decompression adds 50-100 ms overhead, acceptable for periodic saves but not for every keystroke.

Encryption for Sensitive Data

If your kanban stores sensitive information (e.g., personal notes, private task labels), encrypt before storing:

npm install crypto-js
import CryptoJS from 'crypto-js';

const SECRET_KEY = 'your-secret-key'; // In production, derive from user password

function Board() {
const [tasks, setTasks] = React.useState(() => {
const encrypted = localStorage.getItem('kanban-tasks');
if (!encrypted) return initialTasks;
try {
const json = CryptoJS.AES.decrypt(encrypted, SECRET_KEY).toString(
CryptoJS.enc.Utf8
);
return JSON.parse(json);
} catch (e) {
console.error('Decryption failed:', e);
return initialTasks;
}
});

function saveToStorage() {
const json = JSON.stringify(tasks);
const encrypted = CryptoJS.AES.encrypt(json, SECRET_KEY).toString();
localStorage.setItem('kanban-tasks', encrypted);
}

// ... rest of component
}

AES encryption ensures that even if someone inspects the browser's localStorage, they can't read the data without the secret key. For production, derive the key from the user's password or use a secure key-derivation function.

Custom Hook: usePersistentState

Encapsulate persistence logic in a reusable hook:

function usePersistentState(key, initialValue) {
const [state, setState] = React.useState(() => {
const saved = localStorage.getItem(key);
return saved ? JSON.parse(saved) : initialValue;
});

const timeoutRef = React.useRef(null);

const setPersistentState = React.useCallback((newState) => {
setState(newState);

if (timeoutRef.current) clearTimeout(timeoutRef.current);
timeoutRef.current = setTimeout(() => {
try {
localStorage.setItem(key, JSON.stringify(newState));
} catch (e) {
console.error(`Failed to persist ${key}:`, e);
}
}, 2000);
}, [key]);

React.useEffect(() => {
return () => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
};
}, []);

return [state, setPersistentState];
}

// Usage
function Board() {
const [tasks, setTasks] = usePersistentState('kanban-tasks', initialTasks);
// Now use setTasks as normal; persistence is automatic
}

This hook abstracts debouncing and localStorage interaction, making it trivial to add persistence to any component state.

Handling Storage Quota Exceeded

localStorage has limits (5-10 MB per origin). Graceful degradation when full:

function saveToStorage(key, data) {
try {
localStorage.setItem(key, JSON.stringify(data));
} catch (e) {
if (e.name === 'QuotaExceededError') {
console.error('localStorage is full. Clearing old entries...');
// Delete old data or ask user to manage storage
localStorage.removeItem('kanban-tasks-archive');
try {
localStorage.setItem(key, JSON.stringify(data));
} catch (retryError) {
alert('Storage full. Please clear browser data to continue.');
}
} else {
throw e;
}
}
}

Key Takeaways

  • Lazy initialization: Read from localStorage in useState initializer to avoid re-fetching on every render.
  • Debounce writes: Batch updates and write every 2-5 seconds instead of on every change.
  • Handle multi-tab conflicts: Listen to storage events to sync across browser tabs.
  • Compress large state: Use lz-string to fit mega-boards within localStorage quota.
  • Encrypt sensitive data: Use AES encryption for privacy-critical information.
  • Graceful degradation: Catch quota-exceeded errors and inform the user.

Frequently Asked Questions

How much data can I store in localStorage?

Most browsers allow 5-10 MB per origin (domain + protocol). For a kanban with 1000 tasks of ~1 KB each uncompressed (1 MB total), you're at the lower end. Compression drops this to 100-200 KB, leaving room for other data.

Is localStorage data persistent across browser updates?

Yes, localStorage survives browser restarts and updates. It's cleared only when the user clears browser data, or when code explicitly calls localStorage.clear(). For longer-term persistence (years), consider IndexedDB, which has higher quotas (50+ MB).

Can I encrypt localStorage data without slowing down my app?

Encryption adds 50-100 ms overhead. For non-sensitive data, skip encryption. For sensitive data, encrypt on save (periodic, debounced) but decrypt only when needed (e.g., on mount). This amortizes the cost.

What's the difference between localStorage and IndexedDB?

localStorage is simpler (key-value, 5-10 MB limit) but synchronous (blocks the main thread). IndexedDB is async, supports queries, and has higher quotas (50+ MB). For kanban, localStorage suffices unless you have 10,000+ cards or need advanced search.

How do I test persistence?

Open DevTools, go to Application tab, find localStorage. Manually edit entries or delete them to simulate data loss. Reload the page and verify your app handles missing data gracefully.

Further Reading