Topic 04

Remembering Choices: localStorage

JavaScriptHands-on

Close a tab and every variable in its scripts is gone. Open the page again and the scripts start from nothing, so a visitor who chose the dark theme yesterday gets the light one today. localStorage fixes that. It is a small store the browser keeps for each site, holding pairs of names and values that survive closing the tab, closing the browser and restarting the computer. A page can put a choice there and read it back on the next visit.

A sticky note on the fridge is a close match. It stays when you leave the room, only people in this house can read it, and anyone who lives there can throw it away without asking you. All three of those facts are true of localStorage, and all three matter. Unlike a sticky note, it holds only text, which is the first thing to learn about it.

Set, Get and Remove

Three methods do almost everything. setItem stores a value under a name, called a key. getItem reads the value stored under a key, and returns null when nothing is stored there. removeItem deletes a key and its value. Try them in the Console on any page of your site.

Console
localStorage.setItem("greeting", "hello");
localStorage.getItem("greeting")
localStorage.getItem("nothing-here")
localStorage.removeItem("greeting");
localStorage.getItem("greeting")
Console output
undefined
'hello'
null
undefined
null

The first line stores the text hello under the key greeting. The second reads it back. The third asks for a key that was never set and gets null, not an error. The fourth removes the greeting, and the last line shows it is gone. Store the greeting again, close the tab, open the site in a new one, and getItem still returns hello: that is the whole point.

Why Does Everything Come Back as Text?

localStorage stores strings and nothing else. Give it a number or an object, and it quietly turns the value into text first, which is not always the text you wanted.

Console
localStorage.setItem("visits", 3);
localStorage.getItem("visits")
localStorage.setItem("project", { title: "Budget Tracker" });
localStorage.getItem("project")
Console output
undefined
'3'
undefined
'[object Object]'

The number 3 comes back as the string 3, in quotes; use Number from Chapter 6 to turn it back. The object is worse: it comes back as the useless text object Object in square brackets, and the title is lost. For objects, Chapter 7's JSON functions do the conversion properly: JSON.stringify on the way in, JSON.parse on the way out.

Console
const settings = { theme: "dark", fontSize: 18 };
localStorage.setItem("settings", JSON.stringify(settings));
const saved = JSON.parse(localStorage.getItem("settings"));
saved.fontSize
Console output
undefined
undefined
undefined
18

The object goes in as JSON text and comes back out as a real object, so saved.fontSize is the number 18, not text. When you are done experimenting, remove the practice keys with removeItem.

What Are the Limits?

Each site has its own store. More exactly, each origin does, the word Chapter 7 met and Chapter 11 defines properly: roughly, one site at one address. Your site cannot read another site's store, and no other site can read yours.

It lives in this browser, on this device, and nowhere else. It is not synced to an account, not shared between two browsers on the same computer, and a private window's store is thrown away when the last private window closes. The space is small, a few megabytes per site, and the visitor can clear it at any moment by clearing the site's data.

It can also fail. A browser with strict privacy settings can block it, and a full store refuses new values. Either way, using it throws: it stops with an error, and as Chapter 6 showed, an error stops the rest of the script. So the site wraps every read and write in try and catch. Code inside try runs normally; if it throws, the browser skips to the catch block instead of stopping the script.

And it is not secret. Any script running on the page can read every key, including scripts from other companies that a site loads, and any code that gets in through the injection from the last chapter. Never put a password, a payment detail or a login token in localStorage. A theme choice is exactly the kind of thing it is for.

The Site's Theme Switch

Chapter 5 gave the site a dark theme that follows the operating system's setting through prefers-color-scheme. Now the visitor gets a button to choose for themselves. The plan: a stored choice wins; with nothing stored, the system setting decides, exactly as before. The current choice is written as a data-theme attribute on the html element, which JavaScript reaches as document.documentElement.

The theme must be applied before the page is first drawn, or a visitor who chose dark sees a flash of light first. So the script that applies it, js/theme.js, is loaded in the head without defer. Topic 2 of the last chapter said a script there runs before the body exists. Here that is the point: the html element already exists, and the script can mark it before anything is shown. Add the script to the head of all three pages, and the button to the header of all three.

Fragment: index.html
<head>
  <meta charset="utf-8">
  <title>Your Name</title>
  <script src="js/theme.js"></script>
  <!-- the stylesheet and js/main.js stay as they are -->
</head>
<!-- inside the header -->
<button type="button" class="theme-toggle" aria-pressed="false">Dark theme</button>

The button's label never changes; aria-pressed says whether the dark theme is on, so a screen reader announces Dark theme, pressed, or not pressed. The first part of js/theme.js reads and writes the stored choice safely, then applies it at once.

js/theme.js
function readTheme() {
  try {
    return localStorage.getItem("theme");
  } catch {
    return null;
  }
}

function saveTheme(theme) {
  try {
    localStorage.setItem("theme", theme);
  } catch {
    // Blocked or full: the theme still changes, but is not remembered.
  }
}

const storedTheme = readTheme();
if (storedTheme === "light" || storedTheme === "dark") {
  document.documentElement.dataset.theme = storedTheme;
}

If reading fails, readTheme answers null, the same as "nothing stored", and the page falls back to the system setting. If saving fails, the theme still changes on screen but is not remembered. The last lines apply a stored choice only if it is one of the two expected values, so a stray value typed in by hand does no harm.

The second part works out which theme is showing right now. It returns the stored choice if there is one; otherwise it asks the same question as Chapter 5's media query, from JavaScript, with window.matchMedia, whose matches property is true when the system prefers dark.

js/theme.js
function currentTheme() {
  const chosen = document.documentElement.dataset.theme;
  if (chosen) {
    return chosen;
  }
  if (window.matchMedia("(prefers-color-scheme: dark)").matches) {
    return "dark";
  }
  return "light";
}

The third part wires up the button. The button is in the body, which does not exist yet when this script runs, so the wiring waits for DOMContentLoaded: an event the document fires once the whole HTML has been read and any deferred scripts have run.

js/theme.js
document.addEventListener("DOMContentLoaded", () => {
  const button = document.querySelector(".theme-toggle");
  if (!button) {
    return;
  }
  button.setAttribute("aria-pressed", String(currentTheme() === "dark"));
  button.addEventListener("click", () => {
    let next = "dark";
    if (currentTheme() === "dark") {
      next = "light";
    }
    document.documentElement.dataset.theme = next;
    saveTheme(next);
    button.setAttribute("aria-pressed", String(next === "dark"));
  });
});

When the HTML is ready, the script looks for the button, and stops quietly on a page that has none, the same guard as in Chapter 8. Then the button's pressed state is set to match the theme on screen. String turns true or false into the text an attribute needs, the way Number turns text into a number. Each click picks the opposite of the current theme, sets it on the html element, stores it, and updates the button.

The last piece is CSS. Chapter 5's dark block needs two changes: the media query must give way when the visitor has chosen light, and the dark values must also apply when the visitor has chosen dark, whatever the system says. The dark values are Chapter 5's, written in both places. This block replaces Chapter 5's dark block; it does not sit next to it.

css/style.css
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --color-text: #e6e6e6;
    --color-bg: #121212;
    --color-accent: #8ab4f8;
  }
}

:root[data-theme="dark"] {
  --color-text: #e6e6e6;
  --color-bg: #121212;
  --color-accent: #8ab4f8;
}

Click the button on the Home page and the whole site turns dark, because every colour comes from the tokens in Chapter 3's design system. Go to the Projects page: still dark. Close the browser, open the site again: still dark, with no flash of light while it loads. Without JavaScript the button does nothing, and the page simply follows the system setting as before.

Where Can You See What Is Stored?

Open DevTools, choose the Application panel, and under Storage open Local storage, then your site's address. The table lists every key and its value; after clicking the button, you see theme with the value dark or light. You can edit a value there, or delete the key, and reload to watch the page follow. Deleting it is the quickest way to test the "nothing stored" path.

Which theme does the page use?
Is a theme stored under the key theme?→use it
Nothing stored: does the system prefer dark?→dark
Nothing stored, and no preference for dark→light
Common Confusions
  • "localStorage saves to my account." It lives in this browser on this device only. Another browser, another device or a cleared history starts empty.
  • "I can store numbers and objects." Everything is stored as a string. Numbers come back as text, and objects need JSON.stringify going in and JSON.parse coming out.
  • "It is private." Every script on the page can read it. Never store passwords, tokens or anything else secret there.
Why It Matters
  • The site now remembers each visitor's theme between visits, without a server or an account.
  • Knowing the limits prevents a whole class of "it worked yesterday" bugs: the store holds only strings, lives in one browser, and can fail.

Knowledge Check

What does localStorage.getItem("theme") return when nothing has been stored under that key?

  • An error saying the key does not exist
  • An empty string, since nothing was ever written
  • The value null, meaning nothing is stored
  • The theme that the operating system prefers now

Why does an object need JSON.stringify before setItem?

  • Because JSON keeps the object hidden from other scripts
  • Because storage holds only strings, and loses the object
  • Because objects are too large to fit in the store as they are
  • Because setItem refuses anything that is not a string

Which of these should never be kept in localStorage?

  • The visitor's chosen light or dark theme setting
  • The last project filter the visitor clicked on
  • A count of how often the page was opened
  • A token that keeps the visitor logged in

Why is js/theme.js loaded in the head without defer?

  • So the theme is set before the page is first drawn
  • So the script can find the header button at once
  • Because a deferred script is not allowed to use storage
  • Because modules are not allowed in the head

You got correct