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 18 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
  • Serialise objects to JSON and read them back safely
  • Keep one state object as the single source of truth
  • Re-render from state instead of patching the page by hand
  • Handle a storage read that fails, because it will

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 in the process you meet the idea that the whole of React is built on: one place that holds the truth, and a screen that is drawn from it.

1. localStorage in four methods

JSapp.js
localStorage.setItem('theme', 'dark');       // save
const theme = localStorage.getItem('theme'); // read, or null if absent
localStorage.removeItem('theme');            // delete one
localStorage.clear();                        // delete everything for this origin
localStoragesessionStorage
Survives a refreshYesYes
Survives closing the tabYes, indefinitelyNo, it is gone
Shared between tabsYes, same originNo, per tab
Rough size limitAbout 5MBAbout 5MB
Good forPreferences, drafts, a small local databaseA multi-step form, a one-visit filter

Everything you store becomes a string

Storage holds text and nothing else. Save the number 42 and you read back the string "42". Save an object without converting it and you read back the literal text [object Object], which is unrecoverable. JSON.stringify on the way in, JSON.parse on the way out, always.

JSapp.js
// what happens without JSON: the object is destroyed on the way in
const store = {};
store['tasks'] = String({ id: 1, text: 'Read Day 11' });
console.log('without JSON:', store['tasks']);

// with JSON: it survives the round trip
store['tasks'] = JSON.stringify([{ id: 1, text: 'Read Day 11', done: false }]);
const back = JSON.parse(store['tasks']);
console.log('with JSON:', back[0].text, '| type:', typeof back);
Console

2. Reading storage safely

Three separate things can go wrong on a read, and beginners handle none of them: the key may not exist, the stored text may be corrupt, and in some browser modes storage throws the moment you touch it.

JSapp.js
// a stand-in for localStorage so this runs anywhere
const fakeStorage = { tasks: '[{"id":1,"text":"Read Day 11"}]', broken: '{oops' };

function load(store, key, fallback) {
  try {
    const raw = store[key];
    if (raw === undefined || raw === null) return fallback;   // never saved
    const parsed = JSON.parse(raw);                            // may throw
    return Array.isArray(parsed) ? parsed : fallback;          // wrong shape
  } 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', []));
Console

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 a SecurityError or a quota error. An unguarded getItem on the first line of your script takes the whole application down before it starts. Wrap it, and carry on without the saved data.

3. One state object

Your Day 9 project probably has let search, let page, let sortBy and let items scattered at the top of a file. That works until two of them get out of step. Putting them in one object makes the whole of "what is true right now" a single thing you can print, save and restore.

JSapp.js
const state = {
  tasks: [
    { id: 1, text: 'Read Day 11', done: true },
    { id: 2, text: 'Refactor to state', done: false }
  ],
  filter: 'all',
  search: '',
  loading: false,
  error: null
};

// derived values are computed, never stored
const visible = () => state.tasks
  .filter((t) => state.filter === 'all' || (state.filter === 'done') === t.done)
  .filter((t) => t.text.toLowerCase().includes(state.search.toLowerCase()));

console.log('all:', visible().length);
state.filter = 'done';
console.log('done only:', visible().map((t) => t.text));
console.log('remaining:', state.tasks.filter((t) => !t.done).length);
Console

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 in the render. The count on screen being wrong is almost always a value that was stored when it should have been derived.

4. The loop: change state, save, render

One function changes state. One function saves it. One function draws it. Every action goes through all three, in that order, with no exceptions.

JSapp.js
function setState(changes) {
  Object.assign(state, changes);
  save();
  render();
}

function save() {
  try {
    localStorage.setItem(KEY, JSON.stringify({ tasks: state.tasks, filter: state.filter }));
  } catch (error) {
    console.warn('Could not save:', error.name);   // full disk, private mode
  }
}

function render() {
  list.innerHTML = visible().map(row).join('');
  count.textContent = `${state.tasks.filter((t) => !t.done).length} left`;
  empty.hidden = visible().length > 0;
}

// every action now looks like this, and nothing else touches the DOM
addButton.addEventListener('click', () => {
  setState({ tasks: [...state.tasks, createTask(input.value)] });
  input.value = '';
});
  • Notice save() stores tasks and filter but not loading or error. Restoring a saved error state on next visit would show a failure that is not happening.
  • [...state.tasks, newTask] builds a new array rather than pushing into the old one. Getting used to that now makes Level 3 far easier.
  • Nothing outside render() writes to the page. That single rule is what stops the screen and the data disagreeing.
Why not just append one row to the DOM instead of re-rendering everything?

Because then you have two sources of truth: the array and the page. They agree today, and in three weeks one code path updates the array and forgets the row, or the other way round. The bug that follows is very 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. When it genuinely is not, that is what React and its virtual DOM are for, and that is Level 3.

5. Saving the whole state, and restoring it

JSapp.js
const DEFAULTS = { tasks: [], filter: 'all' };

function restore(raw) {
  try {
    const saved = JSON.parse(raw ?? 'null');
    if (!saved || typeof saved !== 'object') return { ...DEFAULTS };
    return {
      ...DEFAULTS,
      ...saved,
      tasks: Array.isArray(saved.tasks) ? saved.tasks : []
    };
  } catch {
    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('{{{'));
Console

Spreading DEFAULTS first is what makes that third case work. A user who saved data last week, before you added a new field, gets the new field from the defaults instead of undefined spreading through your app. Your saved data is a schema, and it has old versions in the wild the moment you ship.

6. Choosing where to keep something

Keep it inWhenExample
A plain variableIt is only needed while the page is openWhether a dropdown is open
sessionStorageIt should survive a refresh but not the tab closingStep 3 of a checkout form
localStorageIt should be there next weekTheme, saved tasks, a draft
The URLIt should be shareable and bookmarkable?search=lagos&page=2
A serverIt must survive a new device, or matters if lostAnything real

Never put anything sensitive in storage

localStorage is readable by any JavaScript running on your page, which includes anything injected through an XSS hole. Auth tokens, personal data and anything you would not print on a poster do not belong there. Day 17 covers why in detail.

Do this now

  1. Refactor your Day 5 task app to a single state object and the change-save-render loop above.
  2. Persist 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 stored in localStorage. It is three lines and it makes the idea concrete.

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 string "[object Object]". The data is gone and cannot be recovered from it. Stringify in, parse out, every time.

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

Because 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. Derive it in the render.

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

Because it throws in private browsing modes, when the user has blocked site data, and when the quota is full. An unguarded call on line one stops the whole application before it starts.

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

So that a value saved before you added a field still gets that field from the defaults. Saved data is a schema with old versions in the wild.

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 storage value starts the app empty rather than breaking it
  • Nothing outside render() writes to the page
  • I watched my key update live in the Application tab

Quick recap

Storage holds strings only: stringify in, parse out · Guard every storage call, because it genuinely throws in real browsers · One state object; derive anything you can calculate · Change state, save, render, in that order, with no exceptions · Nothing sensitive in localStorage, ever

Tomorrow, Day 12: the browser is not just a page renderer. It knows where you are, what is on screen, and what is in the clipboard.

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.