Courses Learn Blog Contact
Get Started →
Lesson 11

Day 11: Storage and Application State

localStorage done safely, one state object as the source of truth, and the change-save-render loop that stops the screen disagreeing with the data.

Webbo3 Level 2: JavaScript Development 11 min read Free

Webbo3 Level 2 · JavaScript Development · Day 11 of 20

Storage and Application State

Making your application remember, and organising what it knows so that the screen can never disagree with the truth.

By the end of today you can

  • Save and reload data with localStorage, and know when sessionStorage is right
  • Turn objects into JSON for storage and read them back safely
  • Keep one state object as the single source of truth
  • Use one loop for every action: change state, save, render
  • Restore saved data safely, even when it is missing, old or corrupt
  • Decide where each kind of data should live

Every application you have built so far forgets everything the moment the page reloads. Your Day 5 tasks, your Day 9 filters: all gone.

Today they stop being gone. And on the way you meet the idea the whole of React is built on: one place that holds the truth, and a screen that is drawn from it.

1. Storage in the Browser

Definition

Web storage is a small space the browser gives your site to save text under names called keys. localStorage keeps it until it is deleted; sessionStorage keeps it only until the tab is closed.

Real-Life Example

localStorage is a notebook you keep in your bag: it is still there tomorrow. sessionStorage is a sticky note on a library desk: it survives while you are sitting there, and is thrown away when you leave.

Types

localStoragesessionStorage
Survives a refreshYesYes
Survives closing the tabYes, indefinitelyNo, it is gone
Shared between tabsYes, on the same siteNo, one per tab
Rough size limitAbout 5MBAbout 5MB
Good forPreferences, drafts, a small local databaseA multi-step form, a one-visit filter

The Four Methods

MethodWhat it does
localStorage.setItem('theme', 'dark')Save a value under a key
localStorage.getItem('theme')Read it back, or null if it was never saved
localStorage.removeItem('theme')Delete one key
localStorage.clear()Delete everything this site saved
What this code doesWe want to save the user's theme choice and prove it survives a real page reload. The picture shows the page after choosing dark and reloading.
index.html
1
2
3
<p>Theme: <b id="current"></b></p>
<button id="dark">Dark</button>
<button id="light">Light</button>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
const current = document.querySelector('#current');

// On load: read the saved theme, or use 'light' if there is none
current.textContent = localStorage.getItem('theme') ?? 'light';

// Save the choice when a button is clicked
function choose(theme) {
  localStorage.setItem('theme', theme);
  current.textContent = theme;
}

document.querySelector('#dark').addEventListener('click', () => choose('dark'));
document.querySelector('#light').addEventListener('click', () => choose('light'));
BROWSERindex.html · after choosing Dark, then reloading

Common Mistake: Storage Only Holds Text

Everything you store becomes a string. Save the number 42 and you read back "42". Save an object without converting it, and you read back the text "[object Object]", and the data is gone for good.

What this code doesWe want to see what comes back from storage when an object is saved directly, and when it is saved as JSON.
index.html
1
2
<p>Without JSON: <b id="without"></b></p>
<p>With JSON: <b id="with"></b></p>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
const task = { id: 1, text: 'Read Day 11' };

// Without JSON: the object is turned into useless text on the way in
localStorage.setItem('task', task);
const raw = localStorage.getItem('task');
document.querySelector('#without').textContent = raw;
console.log('without JSON:', raw);

// With JSON: it survives the round trip
localStorage.setItem('task', JSON.stringify(task));
const back = JSON.parse(localStorage.getItem('task'));
document.querySelector('#with').textContent = `${back.text} (${typeof back})`;
console.log('with JSON:', back.text, typeof back);
BROWSERindex.html
CONSOLE   BROWSER DEVTOOLS
without JSON: [object Object]
with JSON: Read Day 11 object
Important Rule JSON.stringify on the way in, JSON.parse on the way out, always (Day 8).
Picture This

Picture the notebook in your bag: it only takes writing. To keep a list, you write the list out (stringify), and to use it again you read it back (parse).

2. Reading Storage Safely

Definition

A safe read gets a stored value back without ever crashing the app. Three things can go wrong, and each needs handling: the key may not exist, the stored text may be corrupt, and in some browser modes storage throws an error the moment you touch it.

Real-Life Example

Opening your notebook: the page you want might be missing, the writing might be smudged, or the bag might be locked. A sensible person copes with all three and carries on, instead of giving up on the day.

Array.isArray(value) checks that the value really is a list, in case something else was saved under that key.

What this code doesWe want a load function that returns something usable whether the key is fine, missing or corrupt.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// A stand-in for localStorage so this runs anywhere
const fakeStorage = { tasks: '[{"id":1,"text":"Read Day 11"}]', broken: '{oops' };

// Read a key safely: always return something usable
function load(store, key, fallback) {
  try {
    const raw = store[key];

    // 1. Never saved
    if (raw === undefined || raw === null) return fallback;

    // 2. Might be corrupt, so parsing may throw
    const parsed = JSON.parse(raw);

    // 3. Might be the wrong shape
    if (!Array.isArray(parsed)) return fallback;
    return parsed;
  } catch (error) {
    console.log(`  (ignored corrupt "${key}": ${error.name})`);
    return fallback;
  }
}

console.log('good key: ', load(fakeStorage, 'tasks', []));
console.log('missing:  ', load(fakeStorage, 'nothing', []));
console.log('corrupt:  ', load(fakeStorage, 'broken', []));
OUTPUT   TERMINAL
➜ webbo3 node app.js
good key:  [ { id: 1, text: 'Read Day 11' } ]
missing:   []
  (ignored corrupt "broken": SyntaxError)
corrupt:   []
Storage Can Throw Just for Being Touched

In Safari private browsing, and in any browser where the user has blocked site data, reading or writing localStorage throws an error. An unguarded getItem on the first line of your script takes the whole app down before it starts. Wrap it in try/catch and carry on without the saved data.

Picture This

Picture the smudged notebook page: you do not throw the notebook away, you start that list fresh and carry on with your day.

3. One State Object

Definition

State is everything that is true about your app right now: the tasks, the filter, the search, whether it is loading. Keeping it in one object makes "what is true" a single thing you can print, save and restore.

Real-Life Example

A shop with one stock book, instead of notes scattered across the counter, the till and the manager's pocket. When the stock book says 3 bags of rice, that is the truth, and everyone reads the same book.

What this code doesWe want all the facts about a task list in one object, with the visible list and the count worked out from it on demand.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Everything that is true right now, in one place
const state = {
  tasks: [
    { id: 1, text: 'Read Day 11', done: true },
    { id: 2, text: 'Refactor to state', done: false }
  ],
  filter: 'all',
  search: ''
};

// Does a task pass the current filter?
function matchesFilter(task) {
  if (state.filter === 'done') return task.done;
  if (state.filter === 'active') return !task.done;
  return true;
}

// Derived values are worked out from state, never stored in it
const visible = () => state.tasks.filter(matchesFilter);
const remaining = () => state.tasks.filter((task) => !task.done).length;

console.log('all:', visible().length);

state.filter = 'done';
console.log('done only:', visible().map((task) => task.text));
console.log('remaining:', remaining());
OUTPUT   TERMINAL
➜ webbo3 node app.js
all: 2
done only: [ 'Read Day 11' ]
remaining: 1
Never Store What You Can Calculate

Do not keep a remainingCount in state beside the tasks. The moment a task is ticked you have two things to update, and one day you will update one and not the other. Calculate it when you render. A wrong number on screen is almost always a value that was stored when it should have been calculated.

Picture This

Picture the one stock book. The number of empty shelves is never written in it: you count it from the book whenever someone asks.

4. The Loop: Change State, Save, Render

Definition

Every action goes through the same three steps, in the same order: change the state, save it, render the page from it. Nothing outside render ever writes to the page.

Real-Life Example

A bank teller: they update your account in the system, the system saves it, and then they print your new balance from the system. They never write a balance on the slip by hand.

How It Works

Object.assign(state, changes) copies the properties of changes into state, so setState({ filter: 'done' }) changes only the filter. [...state.tasks, newTask] builds a new list with one more task (Day 3 spread).

What this code doesWe want a task list built on the loop, then prove it: add two tasks, tick one, and reload the page for real.
index.html
1
2
3
<form id="add"><input id="text" placeholder="New task"> <button>Add</button></form>
<ul id="list"></ul>
<p id="count"></p>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
const KEY = 'webbo3.tasks';
const list = document.querySelector('#list');
const count = document.querySelector('#count');

// Restore what was saved last time, safely
function restore() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY) ?? 'null');
    if (saved && Array.isArray(saved.tasks)) return saved;
  } catch (error) {
    console.log('Saved data was unreadable, starting fresh');
  }
  return { tasks: [] };
}

const state = restore();

// 1. Change: every action goes through here
function setState(changes) {
  Object.assign(state, changes);
  save();
  render();
}

// 2. Save
function save() {
  try {
    localStorage.setItem(KEY, JSON.stringify({ tasks: state.tasks }));
  } catch (error) {
    console.log('Could not save:', error.name);
  }
}

// 3. Render: the only function that writes to the page
function render() {
  list.innerHTML = state.tasks
    .map((task) => `<li data-id="${task.id}">${task.done ? '[x]' : '[ ]'} ${task.text}</li>`)
    .join('');
  const left = state.tasks.filter((task) => !task.done).length;
  count.textContent = `${left} left`;
}

// Actions: each one only calls setState
document.querySelector('#add').addEventListener('submit', (event) => {
  event.preventDefault();
  const input = document.querySelector('#text');
  const task = { id: Date.now(), text: input.value.trim(), done: false };
  setState({ tasks: [...state.tasks, task] });
  input.value = '';
});

list.addEventListener('click', (event) => {
  const id = Number(event.target.closest('li').dataset.id);
  const tasks = state.tasks.map((task) => {
    if (task.id === id) return { ...task, done: !task.done };
    return task;
  });
  setState({ tasks });
});

render();
BROWSERindex.html · after adding two tasks, ticking the first, and reloading

Both tasks, and the tick, came back after the reload, because render drew the page from state, and state was restored from storage.

Common Mistake: Two Sources of Truth

Appending one row straight to the page instead of re-rendering means the array and the page are both "the truth". They agree today; in three weeks one code path updates the array and forgets the row, and the bug is hard to find because the data is right and the screen is wrong. Re-rendering the whole list is fast enough for hundreds of rows.

What If the Saved Data Is Broken?

What this code doesWe want to corrupt the saved tasks from outside the page, the way you will in DevTools, reload, and check the app starts cleanly instead of crashing.
index.html
1
2
3
<form id="add"><input id="text" placeholder="New task"> <button>Add</button></form>
<ul id="list"></ul>
<p id="count"></p>
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
const KEY = 'webbo3.tasks';
const list = document.querySelector('#list');
const count = document.querySelector('#count');

// Restore what was saved last time, safely
function restore() {
  try {
    const saved = JSON.parse(localStorage.getItem(KEY) ?? 'null');
    if (saved && Array.isArray(saved.tasks)) return saved;
  } catch (error) {
    console.log('Saved data was unreadable, starting fresh');
  }
  return { tasks: [] };
}

const state = restore();

// 1. Change: every action goes through here
function setState(changes) {
  Object.assign(state, changes);
  save();
  render();
}

// 2. Save
function save() {
  try {
    localStorage.setItem(KEY, JSON.stringify({ tasks: state.tasks }));
  } catch (error) {
    console.log('Could not save:', error.name);
  }
}

// 3. Render: the only function that writes to the page
function render() {
  list.innerHTML = state.tasks
    .map((task) => `<li data-id="${task.id}">${task.done ? '[x]' : '[ ]'} ${task.text}</li>`)
    .join('');
  const left = state.tasks.filter((task) => !task.done).length;
  count.textContent = `${left} left`;
}

// Actions: each one only calls setState
document.querySelector('#add').addEventListener('submit', (event) => {
  event.preventDefault();
  const input = document.querySelector('#text');
  const task = { id: Date.now(), text: input.value.trim(), done: false };
  setState({ tasks: [...state.tasks, task] });
  input.value = '';
});

list.addEventListener('click', (event) => {
  const id = Number(event.target.closest('li').dataset.id);
  const tasks = state.tasks.map((task) => {
    if (task.id === id) return { ...task, done: !task.done };
    return task;
  });
  setState({ tasks });
});

render();
BROWSERindex.html · after corrupting storage and reloading
CONSOLE   BROWSER DEVTOOLS
Saved data was unreadable, starting fresh
Picture This

Picture the bank teller: change the account, save it, print from it. Three steps, the same order, every time, and nothing is ever written on the slip by hand.

5. Restoring Saved Data That Is Out of Date

Definition

Saved data outlives your code. Defaults are the values your app starts with; spreading them underneath whatever was saved means a user who saved last week, before you added a new field, still gets that field.

Real-Life Example

A new form adds a "Phone number" box. Old paper forms in the filing cabinet do not have it. When you read an old form, you treat the missing box as blank, rather than rejecting the whole form.

What this code doesWe want restore to give a complete, usable state for a first visit, a normal visit, data saved by an older version, and corrupt data.
app.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// What a brand new user starts with
const DEFAULTS = { tasks: [], filter: 'all' };

function restore(raw) {
  try {
    const saved = JSON.parse(raw ?? 'null');

    // Nothing saved yet, or not an object
    if (!saved || typeof saved !== 'object') return { ...DEFAULTS };

    // Defaults first, then whatever was saved on top
    return {
      ...DEFAULTS,
      ...saved,
      tasks: Array.isArray(saved.tasks) ? saved.tasks : []
    };
  } catch (error) {
    // Corrupt: start fresh
    return { ...DEFAULTS };
  }
}

console.log('first ever visit:', restore(null));
console.log('normal restore:  ', restore('{"tasks":[{"id":1}],"filter":"done"}'));
console.log('old saved shape: ', restore('{"filter":"done"}'));
console.log('corrupt:         ', restore('{{{'));
OUTPUT   TERMINAL
➜ webbo3 node app.js
first ever visit: { tasks: [], filter: 'all' }
normal restore:   { tasks: [ { id: 1 } ], filter: 'done' }
old saved shape:  { tasks: [], filter: 'done' }
corrupt:          { tasks: [], filter: 'all' }

The third line is the point: the saved data from an older version had no tasks field, and the defaults filled it in.

Picture This

Picture the old paper forms in the cabinet: you read them with today's form in mind, and fill any missing box with the default.

6. Choosing Where to Keep Something

Definition

Different data needs to live for different lengths of time, and be seen by different people. Choosing the right place is part of designing the app.

Real-Life Example

A shopping list on your hand (gone after the trip), in your notebook (there next week), on the family fridge (everyone sees it), or at the bank (safe even if your house burns down).

Keep it inWhenExample
A plain variableOnly needed while the page is openWhether a dropdown is open
sessionStorageShould survive a refresh but not closing the tabStep 3 of a checkout
localStorageShould be there next weekTheme, saved tasks, a draft
The URLShould be shareable and bookmarkable?search=lagos&page=2
A serverMust survive a new device, or matters if lostAnything real
Never Put Anything Sensitive in Storage

localStorage can be read by any JavaScript running on your page, including anything injected through a security hole. Passwords, login tokens and personal data do not belong there. Day 17 explains why in detail.

Picture This

Picture the list on your hand, in your notebook, on the fridge, or in the bank. The question is always: how long must it last, and who needs to see it?

Do This Now

  1. Refactor your Day 5 task app to one state object and the change, save, render loop.
  2. Save the tasks and the current filter. Refresh and confirm both come back.
  3. Open DevTools, Application tab, Local Storage, and watch your key change as you use the app.
  4. Break it on purpose: edit the stored value in DevTools to {{{ and reload. Your app must start with an empty list, not a blank white screen.
  5. Add a theme toggle saved in localStorage.
The Test That Proves It

Add three tasks, tick one, set the filter to Active, close the browser completely, reopen it and go back to the page. All three tasks, the tick and the filter should be exactly as you left them. If the filter came back but the tick did not, you saved one and forgot the other.

Checkpoint

Q1. You saved an object without JSON.stringify. What do you read back?

The text "[object Object]". The data is gone and cannot be recovered. Stringify in, parse out, every time.

Q2. Why should a remaining-items count never be stored in state?

It can be calculated from the tasks. Storing it means two things to keep in step, and eventually one gets updated and the other does not.

Q3. Why wrap every localStorage call in try/catch?

It throws in private browsing, when the user has blocked site data, and when storage is full. An unguarded call on line one stops the whole app before it starts.

Q4. Why spread DEFAULTS before the saved data when restoring?

So data saved before you added a field still gets that field from the defaults. Saved data has old versions in the wild the moment you ship.

Tick Before You Move On

  • ☐ My app has one state object and a change, save, render loop
  • ☐ Tasks and filter survive closing the browser entirely
  • ☐ A corrupted stored value starts the app empty rather than breaking it
  • ☐ Nothing outside render writes to the page
  • ☐ I calculate counts instead of storing them
  • ☐ I watched my key update live in the Application tab

Quick Recap

Storage holds text only: stringify in, parse out. localStorage lasts; sessionStorage ends with the tab. Guard every storage call, because it really does throw in real browsers. One state object holds the truth; anything you can calculate, you calculate. Change state, save, render: in that order, for every action, with no exceptions. Restore with defaults underneath, and never store anything sensitive.

Final Scenario

A student uses your task app on the bus, closes the browser to save battery, and opens it again at home.

Everything is exactly where they left it, because every tick went through setState, was saved, and the page was drawn from state. Even the day their browser corrupted the saved data, the app simply started fresh instead of showing a white screen.

An app that remembers, and never lies about what it remembers: that is state done properly.

Tomorrow, Day 12: what the browser can already do. It knows the address bar, the clipboard, what is on screen and more, and using those beats rebuilding them.

Learn this with a real instructor

Free lessons take you a long way on your own. The full programmes add live classes, assignments, Skill Points and a certificate — built for African students.