Skip to editor content
learningjavascript.orglesson 13 of 25

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.

What You'll Learn

By the end of this lesson you will write the save/load/merge trio the capstone's persistence layer is made of: a function that serializes a whole collection to one storage key, one that reads it back and falls back to a default when the key is missing or the stored text is corrupt, and one that updates state without mutating it. You arrive able to build and read objects and arrays (05-data-structures, 06-objects-and-prototypes) and to recover from a throw with try/catch (09-error-handling); you leave able to make an app's data survive a reload.

This lesson is required for the final project. The Task Manager remembers your tasks between visits, and the code that makes that true is the one you write here — saveTasks and loadTasks in 15-capstone-project are this lesson's save and load with tasks in them.

One thing to be clear about up front, because it explains why this lesson sits where it does. localStorage comes from the browser, not from the JavaScript language itself — it is a Web API, so a Node.js program has no localStorage at all, no matter how long it waits for one. It is also extension-tier material that has been pulled forward: it appears here, before the capstone rather than in the optional tail, for one reason: the capstone needs persistence, and you cannot build a task manager that forgets everything on refresh. Treat the storage calls themselves as a small API to look up; the transferable skill is the serialize / rehydrate / fall-back discipline wrapped around them, which is the same at any storage boundary.

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 the window storage event 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 playground blocks swap real storage for an explicit in-memory store object (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:

StorageLifetime
localStoragePersists until explicitly cleared — survives browser restarts
sessionStorageCleared 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.

Debug

savePrefs writes the user's preferences and loadPrefs reads them back on the next page load. It works the very first time — with nothing stored yet, the defaults come back fine — and then breaks the moment anything has actually been saved. Commit a hypothesis about WHY before changing anything: what exactly is sitting in the store when loadPrefs runs?

const assert = require('assert');

// The string-only stand-in for localStorage used throughout this lesson:
// setItem coerces its value through String(), exactly as the real one does.
const store = {};
function setItem(key, value) {
store[key] = String(value);
}
function getItem(key) {
return key in store ? store[key] : null;
}

// Save the user's preferences, then read them back on the next "page load".
function savePrefs(prefs) {
setItem("prefs", prefs);
}

function loadPrefs() {
const raw = getItem("prefs");
if (raw === null) return { theme: "light", fontSize: 14 };
return JSON.parse(raw);
}

savePrefs({ theme: "dark", fontSize: 18 });
const loaded = loadPrefs();

assert.deepStrictEqual(loaded, { theme: "dark", fontSize: 18 }, 'got ' + JSON.stringify(loaded));

console.log("Theme:", loaded.theme);
console.log("Font size:", loaded.fontSize);

Expected output:

Theme: dark
Font size: 18

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);

When you run it, this is the failure, verbatim — worth reading closely, because the text it refuses to parse is the whole diagnosis:

SyntaxError: "[object Object]" is not valid JSON
    at JSON.parse (<anonymous>)

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 WAS written, but the stored text is corrupted — a half-written string like {not valid json. getItem hands that string back happily; JSON.parse is what meets it. Back in 09-error-handling you built safeParse for exactly this shape. What does JSON.parse do with that text, and what did that lesson do about it?

Ordering the read

Every read from storage is the same four questions in the same order, and getting the order wrong is how a storage layer crashes on data it should have handled. Here is the setting for the block below — the string-only stand-in and one helper, with loadList's body left out:

const store = { tasks: '["write","test"]', notes: '{"a":1}', broken: "{not json" };

function getItem(key) {
	return key in store ? store[key] : null;
}

function parseOrNull(text) {
	try {
		return JSON.parse(text);
	} catch (error) {
		return null;
	}
}

function loadList(key) {
	// the six lines of the block below go here
}

// loadList("tasks") -> "write,test"   loadList("nope")   -> "missing"
// loadList("broken") -> "corrupt"     loadList("notes")  -> "not a list"

Arrange the code

Reassemble the body of loadList(key), the read-parse-guard-fallback sequence the build task below asserts. It must return the stored list when the key holds a valid array, the string missing when the key was never written, corrupt when the stored text is not valid JSON, and not a list when the stored JSON is valid but is not an array. Every line here reads a name the line above it produced — why must they run in this order and no other?

  1. return Array.isArray(parsed) ? parsed.join(",") : "not a list";
  2. const parsed = parseOrNull(text);
  3. if (raw === null) return "missing";
  4. const raw = getItem(key);
  5. const text = raw.trim();
  6. if (parsed === null) return "corrupt";

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:

  1. An undefined field through the round-trip. Call save("prefs", { theme: "dark", onSave: undefined, count: 3 }), then log rawGet("prefs") and load("prefs", {}). Write down the stored string and the loaded object before you run it — in particular, how many keys each one has. (Output-changing: you are predicting the serialized string and the loaded object.)
  2. How far the immutability reaches. Build before = { user: { name: "Ann" }, theme: "dark" }, call after = merge(before, { theme: "light" }), then set after.user.name = "Changed" and log before.user.name. Write down what prints — "Ann" or "Changed" — before running. (Output-changing: you are predicting whether the original's nested value changed.)

After you have written your prediction down, check it against what actually happens.

What you should have seen
  1. JSON.stringify silently drops properties whose value is undefined, so both the stored text and the rehydrated object have no onSave key at all — only theme and count. The key does not survive as null, and it does not survive as undefined; it is gone.
  2. "Changed" prints. { ...state, ...patch } is a shallow copy, so after.user and before.user are the same nested object and mutating one is visible through the other. Protecting the nested object too means copying it as well — { ...state, user: { ...state.user } } — or reaching for a deep clone such as structuredClone.

Transfer

The mechanism you just built is not really about localStorage. It is: a boundary that can only carry text, so structured data must be encoded on the way out and decoded — defensively, because what comes back was not necessarily written by you — on the way in. A URL query string is a different boundary with the same constraint: everything after the ? is text, and anyone can edit it. Which statement correctly carries this lesson's discipline across to that setting, naming what stays the same and what changes?

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

  • localStorage persists data across browser restarts; sessionStorage is cleared when the tab closes
  • Both APIs only store strings — use JSON.stringify and JSON.parse to handle objects and arrays
  • Always provide a fallback when reading from storage — getItem returns null for 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 storage event lets you react to changes made in other tabs on the same origin
  • localStorage is 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