JAVASCRIPT / PROJECTS AND PROFESSIONAL CRAFT
Project: a to-do list with localStorage persistence
Build a to-do list that survives reload by saving a validated JSON snapshot to localStorage after every change and rebuilding state on startup.
What you will learn
- Read storage once at startup and write a full snapshot after every state change
- Wrap JSON.parse in try/catch and validate shape before trusting stored data
- Namespace and version the storage key so old data can be migrated, not crashed on
- Persist the todos themselves; keep filter and input drafts out of storage
Understanding Project: a to-do list with localStorage persistence
localStorage is a synchronous string-to-string map scoped to the page's origin, and that is the whole of its contract: it has no schema, no types, and no notion of your Todo objects. The working model is that your in-memory array is the source of truth and storage is a snapshot of it. You read that snapshot once when the script starts, and you overwrite it after every mutation, which keeps the two in step without any diffing logic.
Because everything comes back as a string, the interesting code lives at two boundaries: a load function that parses and a save function that serializes. Load has to be defensive, because the bytes in storage were written by an older version of your code, or by a user typing into the DevTools Application panel. getItem returns null for a missing key, JSON.parse throws SyntaxError on a truncated value, and a value that parses fine can still be an object where you expected an array, so load should validate the shape and fall back to an empty list rather than letting a bad string break the first render.
Routing every add, toggle, and delete through a single update function that saves and then renders is what keeps the UI and the stored snapshot from drifting apart. It also forces you to decide what is durable: the todo text and its done flag survive a reload, but the active filter, the half-typed input, and which row has focus are ephemeral and belong in memory only. One consequence of persistence is that ids now outlive the page, so generate them with crypto.randomUUID() or a timestamp at creation time, never from an array index that shifts the moment an item is deleted.
// localStorage stores strings only, so this shim behaves the same way.
// In a page, delete it and use the real `localStorage`.
const memory = new Map();
const storage = {
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
setItem: (k, v) => { memory.set(k, String(v)); }
};
const KEY = 'todos/v1';
function loadTodos() {
const raw = storage.getItem(KEY);
if (raw === null) return []; // first visit, nothing saved yet
try {
const data = JSON.parse(raw);
if (!Array.isArray(data)) return [];
return data
.filter((t) => typeof t?.id === 'number' && typeof t?.title === 'string')
.map((t) => ({ id: t.id, title: t.title, done: Boolean(t.done) }));
} catch {
return []; // unparsable value, start clean
}
}
function saveTodos(todos) {
storage.setItem(KEY, JSON.stringify(todos));
}
let todos = loadTodos();
console.log('first visit:', todos);
todos = [...todos, { id: 1, title: 'buy milk', done: false }];
saveTodos(todos);
console.log('raw value:', storage.getItem(KEY));
todos = todos.map((t) => (t.id === 1 ? { ...t, done: true } : t));
saveTodos(todos);
console.log('after reload:', loadTodos());
storage.setItem(KEY, '[{"id":1,'); // a truncated write from an old bug
console.log('after corruption:', loadTodos());localStorage holds strings, not state: the in-memory array is the source of truth and storage is a snapshot you serialize on write and validate on read.
Worked examples
Every change through one save-and-render step
Shows a single update function that replaces state, persists the durable part, and redraws, while the filter stays out of storage.
const memory = new Map();
const storage = {
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
setItem: (k, v) => { memory.set(k, String(v)); }
};
const KEY = 'todos/v1';
let state = { todos: [], filter: 'all' };
let writes = 0;
function render() {
const shown = state.todos.filter((t) =>
state.filter === 'done' ? t.done : state.filter === 'open' ? !t.done : true);
console.log(state.filter + ':', shown.map((t) => (t.done ? 'x ' : '- ') + t.title).join(', '));
}
function update(change) {
state = change(state);
storage.setItem(KEY, JSON.stringify(state.todos));
writes++;
render();
}
update((s) => ({ ...s, todos: [...s.todos, { id: 1, title: 'walk', done: false }] }));
update((s) => ({ ...s, todos: [...s.todos, { id: 2, title: 'read', done: false }] }));
update((s) => ({ ...s, todos: s.todos.map((t) => (t.id === 1 ? { ...t, done: true } : t)) }));
update((s) => ({ ...s, filter: 'done' }));
console.log('writes:', writes);
console.log('stored:', storage.getItem(KEY));Example explained
Line 1update takes a function that returns a new state object, so save always receives the state that was just rendered.
Line 2storage.setItem is called with JSON.stringify(state.todos) only, so the filter change never reaches storage.
Line 3writes is 4 because the filter change still goes through update; that write is harmless but shows why a filter-only action could skip persistence.
Line 4The stored string proves the toggle was captured: the walk entry has "done":true.
Migrating data written by an older version
Reads a legacy array of plain strings under an unversioned key and rewrites it as objects under todos/v1.
const memory = new Map([['todos', '["buy milk","walk dog"]']]);
const storage = {
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
setItem: (k, v) => { memory.set(k, String(v)); },
removeItem: (k) => { memory.delete(k); }
};
function migrate() {
if (storage.getItem('todos/v1') !== null) return 'already current';
const old = storage.getItem('todos');
if (old === null) return 'nothing to migrate';
const titles = JSON.parse(old);
const upgraded = titles.map((title, i) => ({ id: i + 1, title, done: false }));
storage.setItem('todos/v1', JSON.stringify(upgraded));
storage.removeItem('todos');
return 'migrated ' + upgraded.length;
}
console.log(migrate());
console.log(storage.getItem('todos/v1'));
console.log(migrate());
console.log(storage.getItem('todos'));Example explained
Line 1The presence of todos/v1 is the migration flag, so migrate is safe to call on every startup.
Line 2Array indexes are acceptable as ids only here, at conversion time, because the legacy format stored no ids at all.
Line 3removeItem drops the old key so a later bug cannot resurrect stale data.
Line 4The final null is what getItem returns for a key that does not exist, which is why load code must test for null before parsing.
JSON round-trips lose Dates and Sets
Demonstrates that stringify flattens rich values, and that reviving them has to be done explicitly on load.
const memory = new Map();
const storage = {
getItem: (k) => (memory.has(k) ? memory.get(k) : null),
setItem: (k, v) => { memory.set(k, String(v)); }
};
const todo = {
id: 1,
title: 'file taxes',
due: new Date('2026-04-15T00:00:00Z'),
tags: new Set(['home']),
note: undefined
};
storage.setItem('naive', JSON.stringify(todo));
console.log(storage.getItem('naive'));
const naive = JSON.parse(storage.getItem('naive'));
console.log(typeof naive.due, naive.tags, 'note' in naive);
const encode = (t) =>
JSON.stringify({ id: t.id, title: t.title, due: t.due.toISOString(), tags: [...t.tags] });
const decode = (raw) => {
const p = JSON.parse(raw);
return { ...p, due: new Date(p.due), tags: new Set(p.tags) };
};
storage.setItem('todo/v1', encode(todo));
const back = decode(storage.getItem('todo/v1'));
console.log(back.due.getUTCFullYear(), back.tags.has('home'), back.due instanceof Date);Example explained
Line 1Date has a toJSON method, so it serializes to an ISO string, but a Set has no enumerable own properties and collapses to {}, losing the tag permanently.
Line 2The property note is dropped entirely because JSON has no representation for undefined.
Line 3After the naive round trip due is a string, so calling due.getTime() would throw TypeError even though the data looks fine in DevTools.
Line 4encode chooses an explicit storable shape and decode revives it, which is why back.due is a real Date and the tag survives.
Important notes
The storage event fires in other tabs and windows of the same origin, never in the tab that performed the write, so use it to refresh duplicate tabs and not as a confirmation callback for your own save.
Storage is roughly 5MB per origin and setItem is synchronous and can throw QuotaExceededError, including on the first write in some private browsing modes, so wrap saves in try/catch if todos can hold long notes.
Common mistakes
Calling localStorage.setItem('todos', todos) without JSON.stringify: setItem coerces the value with String(), so an array of objects is stored as "[object Object],[object Object]" and the next load throws SyntaxError on parse.
Writing const todos = JSON.parse(localStorage.getItem('todos/v1')) with no null check: on a first visit getItem returns null and JSON.parse(null) quietly returns null rather than throwing, so the first todos.map crashes with "Cannot read properties of null".
Using the array index as a todo id: after one delete the remaining indexes shift, so the id stored on the button no longer matches the same item and a reload makes the click toggle or remove the wrong row.
Try it yourself
Change, predict, then run
Extend loadTodos so a stored todo missing done defaults to false and a todo whose title trims to an empty string is discarded, then hand-edit the todos/v1 value in DevTools to [{"id":1,"title":"ok"},{"id":2,"title":" "}] and reload to confirm exactly one item renders.
Open the JavaScript workspaceCheck your understanding
A to-do app saves after every change but its render function also calls localStorage.getItem and JSON.parse to decide which rows to draw. What is the main problem with that?
- There are now two sources of truth that can disagree, and every render pays a synchronous parse on the main thread
- The storage event will stop firing in other tabs because the key is read too often
- JSON.stringify will begin dropping the done field once the same key is read and written in the same tick
- Repeated reads will push the origin past its storage quota
Show answer
Rendering from storage instead of from the in-memory array means a failed or deferred write silently produces a UI that no longer matches state, and getItem plus JSON.parse is blocking work repeated on every frame you draw. The quota answer is tempting because quotas are a real localStorage limit, but reading adds no bytes; only setItem can exceed the quota.