Local Storage and State
Every time a user refreshes a page, JavaScript starts fresh. Variables are gone, user preferences are lost, and any work in progress disappears. The Web Storage API — specifically localStorage and sessionStorage — solves this by giving you a simple key-value store that survives page reloads.
Beyond persistence, this lesson introduces the concept of state: the data your application depends on at any given moment. Learning to manage state explicitly, even without a framework, is one of the most important skills you can develop as a JavaScript developer.
How this lesson runs
This lesson has code in two kinds of places, and the difference is not cosmetic:
- Browser-storage code is shown as reference.
localStorage,sessionStorage, and thewindowstorageevent only exist in a real browser. The in-page runner is a headless Node sandbox with none of them, so every snippet that actually touches those APIs is plain reference code — save it into an HTML file and open it in a browser to watch it work. - The state logic runs right here. The valuable, testable part of this lesson is not the storage call itself — it's the serialize-on-change, rehydrate-with-a-fallback, immutable-update logic wrapped around it. Where that logic is the point, the runnable
playgroundblocks swap real storage for an explicit in-memorystoreobject (narrated as such, exactly like the Predict block below), so you can Run them and read the real console output without leaving the page.
The Web Storage API
Both localStorage and sessionStorage share the same API. The difference is lifetime:
| Storage | Lifetime |
|---|---|
localStorage | Persists until explicitly cleared — survives browser restarts |
sessionStorage | Cleared when the browser tab is closed |
Both are synchronous, store only strings, and have a per-origin size limit of roughly 5–10 MB. Part of that range is one limit described two ways: JavaScript stores each character as two bytes, so Chrome's cap of about 5 million characters is also about 10 MB of storage — count characters and you get 5, count bytes and you get 10. That is Chrome, at least; browsers genuinely differ in both the cap they set and how they count it, which is why the advice is to stay well under the low end rather than to budget against a specific number.
The core methods are the same for both (browser reference — save into an HTML file to run it):
// Store a value
localStorage.setItem("username", "alice");
// Retrieve it
const name = localStorage.getItem("username");
console.log(name); // "alice"
// Remove a single key
localStorage.removeItem("username");
console.log(localStorage.getItem("username")); // null
// Clear everything
localStorage.setItem("a", "1");
localStorage.setItem("b", "2");
console.log(localStorage.length); // 2
localStorage.clear();
console.log(localStorage.length); // 0
Notice that getItem returns null — not undefined — when a key does not exist. Always check for null explicitly when reading values that might not be set yet.
Storing Objects with JSON
localStorage can only store strings. If you try to store an object directly, it gets coerced to "[object Object]", which is useless. The solution is JSON.stringify on the way in and JSON.parse on the way out. This is browser reference code; the Predict block just below lets you run the same serialization idea against an in-memory stand-in.
const settings = {
theme: "dark",
fontSize: 16,
notifications: true,
};
// Serialize the object to a JSON string before storing
localStorage.setItem("settings", JSON.stringify(settings));
// Parse it back when reading
const raw = localStorage.getItem("settings");
const loaded = raw ? JSON.parse(raw) : null;
console.log(loaded); // { theme: "dark", fontSize: 16, notifications: true }
console.log(loaded.theme); // "dark"
console.log(loaded.fontSize); // 16 (number, not string)
// Clean up
localStorage.removeItem("settings");
JSON.parse restores the correct types too — numbers stay numbers, booleans stay booleans. The round-trip is faithful for plain objects and arrays whose values are strings, finite numbers, booleans and null — but it is not lossless in general. Properties set to undefined are dropped entirely, undefined inside an array becomes null, NaN and Infinity become null, and a Date comes back as a string, not a Date. You will see the dropped-undefined case for yourself in variation 1 of the build task below.
Forgetting the JSON.stringify step is the classic first mistake, and its failure is quiet rather than loud. Storage coerces whatever you hand it through String(), so a plain object becomes the literal text "[object Object]". The store below is a tiny stand-in for that string-only behavior — it runs on this page, unlike real localStorage, which the in-page runner does not have.
Predict
A developer skips JSON.stringify and stores the object directly, then reads it back and accesses a property. What do the two logs show?
// A stand-in for Web Storage's string-only behavior: setItem coerces
// its value through String(), exactly as a real localStorage does.
const store = {};
function setItem(key, value) {
store[key] = String(value);
}
function getItem(key) {
return key in store ? store[key] : null;
}
const settings = { theme: "dark", fontSize: 16 };
setItem("settings", settings); // no JSON.stringify — the object goes in raw
const raw = getItem("settings");
console.log(raw);
console.log(raw.theme);Managing State as a Single Object
A common pattern is to represent your entire application state as one object, serialize it on every change, and rehydrate it on load. This keeps your persistence logic in one place and makes it easy to reason about what your app remembers. Run this one — the store object is a string-only stand-in for localStorage so the serialize/rehydrate logic executes on the page; in a real app the two store calls would be localStorage.getItem/localStorage.setItem.
// A string-only stand-in for localStorage so this runs in the page's
// Node sandbox. In the browser, swap these two calls for the real
// localStorage.getItem / localStorage.setItem.
const store = {};
const storage = {
getItem: (key) => (key in store ? store[key] : null),
setItem: (key, value) => {
store[key] = String(value);
},
clear: () => {
for (const key of Object.keys(store)) delete store[key];
},
};
// Define the initial shape of state
const defaultState = {
counter: 0,
lastUpdated: null,
history: [],
};
// Load persisted state, falling back to defaults
function loadState() {
const raw = storage.getItem("appState");
return raw ? JSON.parse(raw) : { ...defaultState };
}
// Save state after every change
function saveState(state) {
storage.setItem("appState", JSON.stringify(state));
}
// State mutation helpers — always return a new object
function increment(state) {
return {
...state,
counter: state.counter + 1,
lastUpdated: new Date().toISOString(),
history: [...state.history, state.counter + 1],
};
}
function reset(state) {
return { ...defaultState };
}
// Boot: load state and apply a few changes
let state = loadState();
console.log("Loaded:", state.counter);
state = increment(state);
state = increment(state);
state = increment(state);
saveState(state);
console.log("Counter:", state.counter); // 3
console.log("History:", state.history); // [1, 2, 3]
console.log("Last updated:", state.lastUpdated);
// Simulate a reload by loading again from storage
const rehydrated = loadState();
console.log("After reload, counter:", rehydrated.counter); // 3
// Clean up
storage.clear();
Notice that increment never mutates the existing state object — it returns a new one using the spread operator. This pattern, called immutable state updates, makes your logic easier to test and debug because each state transition is an explicit transformation.
Recall
No scrolling. Your loadState reads a key that has never been written. What does localStorage.getItem return for a missing key, and how should loadState react?
Reacting to Storage Changes
When two browser tabs are open on the same origin, localStorage is shared. You can listen for changes made in other tabs using the storage event on window. This is useful for syncing state across tabs — for example, logging out everywhere when the user signs out in one tab. This is browser reference code — window and the storage event don't exist in the in-page runner, so save it into an HTML file and open it in two tabs to see it fire.
// This event only fires in OTHER tabs, not the one that made the change
window.addEventListener("storage", (event) => {
console.log("Key changed:", event.key);
console.log("Old value:", event.oldValue);
console.log("New value:", event.newValue);
console.log("Origin:", event.url);
});
// Simulate what another tab might do
localStorage.setItem("session", JSON.stringify({ userId: 42 }));
localStorage.removeItem("session");
// The event listener above would fire in any other open tab,
// but not in this one — that is the intended behavior.
console.log("Storage event listener registered.");
console.log("Open this page in another tab to see cross-tab sync.");
// Clean up
localStorage.clear();
The storage event is a low-cost way to keep multiple tabs in sync without polling or a WebSocket connection.
Try It Yourself
Build a small note-taking state manager. It should support adding notes, listing them, and clearing them — all persisted across reloads. Run it below: the same string-only store stand-in swaps in for localStorage so the load/save/CRUD logic executes here. In a real app, storage.getItem/storage.setItem are just localStorage.getItem/localStorage.setItem.
// String-only stand-in for localStorage so this runs in the page's
// Node sandbox. In the browser, these are localStorage calls.
const store = {};
const storage = {
getItem: (key) => (key in store ? store[key] : null),
setItem: (key, value) => {
store[key] = String(value);
},
clear: () => {
for (const key of Object.keys(store)) delete store[key];
},
};
const STORAGE_KEY = "notes";
function loadNotes() {
const raw = storage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : [];
}
function saveNotes(notes) {
storage.setItem(STORAGE_KEY, JSON.stringify(notes));
}
let nextId = 1; // Date.now() ids collide when notes are added in the same millisecond
function addNote(notes, text) {
const note = {
id: nextId++,
text,
createdAt: new Date().toISOString(),
};
return [...notes, note];
}
function deleteNote(notes, id) {
return notes.filter((note) => note.id !== id);
}
// --- Simulate usage ---
let notes = loadNotes();
console.log("Starting notes:", notes.length);
notes = addNote(notes, "Buy groceries");
notes = addNote(notes, "Finish the lesson on localStorage");
notes = addNote(notes, "Call the dentist");
saveNotes(notes);
console.log("After adding 3 notes:", notes.length);
notes.forEach((n) => console.log(" -", n.text));
// Delete the first note
const firstId = notes[0].id;
notes = deleteNote(notes, firstId);
saveNotes(notes);
console.log("After deleting first note:", notes.length);
notes.forEach((n) => console.log(" -", n.text));
// Simulate a page reload
const reloaded = loadNotes();
console.log("After reload:", reloaded.length, "notes");
// Clean up
storage.clear();
console.log("Storage cleared.");
Try extending this: add a pinned boolean to each note, or write a updateNote function that replaces a note by id. Notice how keeping state as a plain array of objects makes all these operations straightforward.
Reading the note manager is not the same as building the storage layer under it. This is a build task: a small program that reports its own pass/fail. You are given the same string-only store stand-in and three empty functions — the save/load/merge trio the capstone's persistence layer is made of. Run it and it fails immediately, telling you which function is still missing. Implement each one until every check passes and it prints All checks passed.
The three functions reuse exactly what this lesson taught: JSON.stringify on the way in and JSON.parse on the way out (the "Storing Objects with JSON" section above), the getItem-returns-null fallback (the Retrieval question above), and the immutable spread update from increment in the state section. The corrupted-data guard — JSON.parse in a try/catch — is the defensive move from 09-error-handling. The starter already has the store, the stubs, and the checks — you write only the logic inside each function. The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
Build
Finish the build. Three functions are stubbed out and the checks below them fail until each one returns the right value. Run it as-is to see which check fails first, decide what that function is missing, then implement the three functions until it prints 'All checks passed.' The checks run top to bottom, so the first failure you see is TODO 1 — implement it first, then work down.
const assert = require('assert');
// An in-memory, string-only stand-in for localStorage so this runs in the
// page's Node sandbox. In the browser, these three helpers are just
// localStorage.setItem / getItem / removeItem. Do NOT change this store.
const store = {};
function rawSet(key, value) {
store[key] = String(value);
}
function rawGet(key) {
return key in store ? store[key] : null;
}
function rawRemove(key) {
delete store[key];
}
// TODO 1: serialize `value` with JSON and write it under `key` via rawSet.
// save("prefs", { theme: "dark" }); rawGet("prefs") -> '{"theme":"dark"}'
function save(key, value) {
// your code here
}
// TODO 2: read `key` via rawGet and JSON.parse it. If the key is missing
// (rawGet returns null) OR the stored text is corrupted (JSON.parse throws),
// return `fallback` instead of crashing.
// load("missing", { n: 0 }) -> { n: 0 }
function load(key, fallback) {
// your code here
}
// TODO 3: return a NEW state object equal to `state` with the keys of `patch`
// overwritten — never mutate `state`.
// merge({ a: 1, b: 2 }, { b: 9 }) -> { a: 1, b: 9 }
function merge(state, patch) {
// your code here
}
// --- Build checks: these must all pass. Do not edit below this line. ---
save("prefs", { theme: "dark", fontSize: 16 });
assert.strictEqual(
rawGet("prefs"),
'{"theme":"dark","fontSize":16}',
"save(key, value) should JSON.stringify the value before writing it to the store",
);
assert.deepStrictEqual(
load("prefs", { theme: "light" }),
{ theme: "dark", fontSize: 16 },
"load(key, fallback) should JSON.parse a stored value back into an object",
);
assert.deepStrictEqual(
load("neverWritten", { count: 0 }),
{ count: 0 },
"load(key, fallback) should return the fallback when the key is missing (rawGet returns null)",
);
rawSet("broken", "{not valid json");
assert.deepStrictEqual(
load("broken", []),
[],
"load(key, fallback) should return the fallback when the stored text is corrupted instead of throwing",
);
const before = { theme: "dark", fontSize: 16 };
const after = merge(before, { fontSize: 20 });
assert.deepStrictEqual(
after,
{ theme: "dark", fontSize: 20 },
"merge(state, patch) should return a new state with patch's keys overwritten",
);
assert.deepStrictEqual(
before,
{ theme: "dark", fontSize: 16 },
"merge(state, patch) must NOT mutate the original state object",
);
console.log("All checks passed.");
console.log("Stored prefs:", rawGet("prefs"));
console.log("Loaded prefs:", load("prefs", {}));
console.log("Missing key falls back to:", load("neverWritten", { count: 0 }));
console.log("Merged state:", merge(before, { fontSize: 20 }));Expected output: All checks passed.
Stored prefs: {"theme":"dark","fontSize":16}
Loaded prefs: { theme: 'dark', fontSize: 16 }
Missing key falls back to: { count: 0 }
Merged state: { theme: 'dark', fontSize: 20 }
Once it passes, try two variations and predict each before running:
- An undefined field through the round-trip. Call
save("prefs", { theme: "dark", onSave: undefined, count: 3 }), then lograwGet("prefs")andload("prefs", {}). Predict the stored string and the loaded object —JSON.stringifysilently drops properties whose value isundefined, so the stored text and the rehydrated object have noonSavekey at all, onlythemeandcount. (Output-changing: you are predicting the serialized string and the loaded object.) - How far the immutability reaches. Build
before = { user: { name: "Ann" }, theme: "dark" }, callafter = merge(before, { theme: "light" }), then setafter.user.name = "Changed"and logbefore.user.name. Predict what prints —{ ...state, ...patch }is a shallow copy, soafter.userandbefore.userare the same nested object, and mutating one is visible through the other. What would you have to change to protect the nested object too? (Output-changing: you are predicting whether the original's nested value changed.)
Capstone milestone
Milestone — the storage layer. The capstone persists tasks by serializing the whole list to a single localStorage key and rehydrating it on load, with a safe fallback when nothing is stored yet. This lesson's note manager is that persistence layer in miniature. Confirm you built the round-trip.
Hint: You don't need the full capstone yet — 08-async-and-promises covered the async, try/catch side of this milestone; here you own the serialize-to-one-key, rehydrate-with-a-fallback side the capstone's saveTasks/loadTasks is built on.
- Saved a collection under one localStorage key with JSON.stringify
- Loaded it back with JSON.parse, falling back to a default when getItem returns null
- Confirmed the data survives a simulated reload by reading it again from storage
Key Takeaways
localStoragepersists data across browser restarts;sessionStorageis cleared when the tab closes- Both APIs only store strings — use
JSON.stringifyandJSON.parseto handle objects and arrays - Always provide a fallback when reading from storage —
getItemreturnsnullfor missing keys - Represent application state as a single object and serialize it on every change; rehydrate it on load
- Immutable state updates (spreading into a new object rather than mutating in place) make state changes predictable and debuggable
- The
storageevent lets you react to changes made in other tabs on the same origin localStorageis synchronous: every call blocks the main thread until it finishes, though at ordinary sizes that is far too little to notice — in Chrome a 1 KB write is under the 0.1 ms measurable floor, 100 KB takes about 0.1 ms, and even a 1 MB write takes about 1.3 ms. A single write is never your problem; a thousand of them inside a scroll or animation handler (about 3.1 ms measured, on top of everything else in the frame) is where it starts to cost you
Pro Tip: Wrap all your localStorage calls in a helper module with a consistent prefix for your app's keys (e.g.,
"myapp:settings"instead of"settings"). This prevents collisions with third-party scripts or browser extensions that share the same origin storage, and makes it trivial to nuke just your app's data without touching anything else.
Next Steps
With client-side storage under your belt, the next lesson covers form validation — how to check user input in the browser, show helpful errors, and keep bad data out before it ever reaches your storage or your server.
Next lesson
Form Validation
Learn client-side form validation with JavaScript. Cover built-in validation, custom rules, real-time feedback, and accessible errors.
25 min