Persistence
Assignment
Three pieces of feedback reach Martina Hostettler.
As a member, I want to see my favorites in every tab, so that I don't have to think about where I marked them.
As a volunteer at the counter, I want to stay signed in after a reload, but not overnight, so that nobody works with my login the next day.
As a member, I want to find a request I started when I quickly check the catalog, so that I don't have to enter everything again.
The acceptance criteria are:
- Favorites survive a reload and are the same in every tab.
- The login survives a reload and ends when the browser closes.
- A request someone started stays in its tab, even after a reload.
Learning goals
After this chapter you can
- tell
localStoragefromsessionStorageand store values as JSON, - connect a store to the browser's storage,
- keep several tabs in step with the
storageevent, - explain what never belongs in the browser's storage,
- ask the server about a login after a reload.
Storage in the browser
Section titled “Storage in the browser”A store lives in the memory of the page and is empty after a reload. For values that should last longer, the browser has the Web Storage API with two storage areas. According to MDN, all tabs of an origin share the same localStorage, and it stays when the browser closes. sessionStorage belongs to one tab instead, survives a reload and ends when the tab closes. Both only store text, so lists and objects go in with JSON.stringify and come back out with JSON.parse.
Chapter 5.2 stored the search text in localStorage, and chapter 5.4 built the composable useLocalStorage for it. Since part 6 the filter lives in the address, and the favorites stay in the browser's storage.
Favorites in localStorage
Section titled “Favorites in localStorage”When it is created, a setup store reads what the storage holds and writes every change back. Because an entry can be broken or come from another version of the app, it checks that a list comes out.
App.vue
<script setup>import { useFavoritesStore } from './favorites.js'import { things } from './things.js'
const favorites = useFavoritesStore()</script>
<template> <p>Favorites: {{ favorites.count }}</p> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :aria-pressed="favorites.isFavorite(thing.id)" @click="favorites.toggle(thing.id)">Favorite</button> </li> </ul></template>
<style scoped>[aria-pressed='true'] { font-weight: 700;}</style>favorites.js
import { defineStore } from 'pinia'import { computed, ref, watch } from 'vue'
const KEY = 'lorraine-commons-favorites'
// The storage only holds text. A damaged entry counts as no favorites.function read() { try { const value = JSON.parse(localStorage.getItem(KEY)) return Array.isArray(value) ? value : [] } catch { return [] }}
export const useFavoritesStore = defineStore('favorites', () => { const ids = ref(read()) const count = computed(() => ids.value.length) // Every change goes into the storage, the next start reads it back watch(ids, (value) => localStorage.setItem(KEY, JSON.stringify(value)), { deep: true }) function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
return { ids, count, isFavorite, toggle }})main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]The running case app uses the composable useLocalStorage from chapter 5.4 inside the store. A setup store can use composables the same way a component does.
import { defineStore } from 'pinia'import { computed } from 'vue'
import { useLocalStorage } from '@/composables/useLocalStorage.js'
export const useFavoritesStore = defineStore('favorites', () => { // The ids of the favourite things, kept across reloads and the same in every tab const ids = useLocalStorage('lorraine-commons-favorites', []) const count = computed(() => ids.value.length)
function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
return { ids, count, isFavorite, toggle }})Several tabs
Section titled “Several tabs”When a tab changes an entry in localStorage, the browser fires the storage event in the other tabs of the same origin. According to MDN, the tab that makes the change doesn't get it. The event names the key and the new value, and the store reads it again.
App.vue
<script setup>import { useFavoritesStore } from './favorites.js'import { things } from './things.js'
const favorites = useFavoritesStore()</script>
<template> <p>Favorites: {{ favorites.count }}</p> <ul> <li v-for="thing in things" :key="thing.id"> {{ thing.name }} <button type="button" :aria-pressed="favorites.isFavorite(thing.id)" @click="favorites.toggle(thing.id)">Favorite</button> </li> </ul></template>
<style scoped>[aria-pressed='true'] { font-weight: 700;}</style>favorites.js
import { defineStore } from 'pinia'import { computed, ref, watch } from 'vue'
const KEY = 'lorraine-commons-favorites'
// The storage only holds text. A damaged entry counts as no favorites.function read() { try { const value = JSON.parse(localStorage.getItem(KEY)) return Array.isArray(value) ? value : [] } catch { return [] }}
export const useFavoritesStore = defineStore('favorites', () => { const ids = ref(read()) const count = computed(() => ids.value.length) // Every change goes into the storage, the next start reads it back watch(ids, (value) => localStorage.setItem(KEY, JSON.stringify(value)), { deep: true }) // Another tab of the same origin changed the favorites, this tab follows window.addEventListener('storage', (event) => { if (event.key === KEY) ids.value = read() })
function isFavorite(id) { return ids.value.includes(id) }
function toggle(id) { ids.value = isFavorite(id) ? ids.value.filter((entry) => entry !== id) : [...ids.value, id] }
return { ids, count, isFavorite, toggle }})main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')things.js
// A few things of the catalogexport const things = [ { id: 1, name: 'Drill', deposit: 20, availableFrom: null }, { id: 2, name: 'Tent for four', deposit: 50, availableFrom: '2026-11-12' }, { id: 5, name: 'Jigsaw', deposit: 20, availableFrom: null }, { id: 6, name: 'Projector', deposit: 80, availableFrom: null }, { id: 15, name: 'Circular saw', deposit: 40, availableFrom: null },]Both examples on this page run in the same origin and therefore share the storage. When you mark a favorite in the first example, the second one follows, because it listens to the event. The first example only follows the second one after a restart.
A draft per tab
Section titled “A draft per tab”A form someone started belongs in sessionStorage. It survives a reload, but it stays in the tab where someone started it and disappears when the tab closes.
App.vue
<script setup>import { reactive, ref, watch } from 'vue'
const KEY = 'lorraine-commons-draft-6'
// The draft belongs to this tab only and ends when the tab closesfunction readDraft() { try { return { from: '', to: '', note: '', ...JSON.parse(sessionStorage.getItem(KEY)) } } catch { return { from: '', to: '', note: '' } }}
const form = reactive(readDraft())const sent = ref(false)
// Every change goes into the draft, an empty form removes itwatch( form, (value) => { if (Object.values(value).every((field) => field === '')) sessionStorage.removeItem(KEY) else sessionStorage.setItem(KEY, JSON.stringify(value)) }, { deep: true },)
function submit() { sent.value = true Object.assign(form, { from: '', to: '', note: '' })}</script>
<template> <h1>Reserve the projector</h1> <form @submit.prevent="submit"> <label for="from">First day</label> <input id="from" v-model="form.from" type="date"> <label for="to">Last day</label> <input id="to" v-model="form.to" type="date"> <label for="note">Note</label> <textarea id="note" v-model="form.note" maxlength="200" /> <button>Send request</button> </form> <p v-if="sent" role="status">Sent, the draft is deleted.</p></template>
<style scoped>form { display: grid; gap: 0.25rem; max-width: 20rem;}</style>main.js
import { createPinia } from 'pinia'import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')An empty form deletes the draft, and after sending the form is empty. In the running case app, the draft replaces the question on leaving from chapter 6.4, because whoever leaves the page finds the input again on coming back.
Nothing secret in storage
Section titled “Nothing secret in storage”Every script of the page can read what's in the browser's storage. The HTML5 Security Cheat Sheet by OWASP warns that a single cross-site scripting hole can steal all the data in these storage areas. It advises against keeping sensitive data or session identifiers there. Passwords, API keys and session identifiers therefore never go in, not into sessionStorage either.
That's why the course API keeps the login in a cookie with HttpOnly. According to MDN, JavaScript can't read such a cookie, but fetch still sends it. Without an expiry date it's a session cookie, which ends when the browser closes. Many browsers restore tabs on the next start, and such cookies along with them. SameSite=Strict sends the cookie only with requests from the same site.
The login after a reload
Section titled “The login after a reload”After a reload the store of the login is empty, but the cookie is still there. The app therefore asks the course API with GET /session on start who is signed in. The action keeps its promise, so it only asks once, even when both the app and the guard call it.
// The session cookie of the last login outlives a reload, the store does not. // So the app asks the course API once who is signed in. Every caller gets the same answer. function restore() { restoring ??= callApi('/session').then(({ ok, data }) => { if (ok) member.value = data }) return restoring }The guard of the team area waits for this answer. Otherwise it would send a signed-in team member to the login page after a reload of /team, because the store doesn't know the answer yet.
// The team area needs a team account. Everybody else signs in first and comes back afterwards.router.beforeEach(async (to) => { if (!to.meta.requiresTeam) return // The store is called inside the guard, when Pinia is installed. After a reload the guard // waits until the course API has said who is signed in. const auth = useAuthStore() await auth.restore() if (!auth.isTeam) return { name: 'login', query: { redirect: to.fullPath } }})In code
Section titled “In code”Work with the examples of this chapter. Read the code and write down what you expect before you change anything.
- Mark two favorites in the first example and then change something in the code, so that the preview restarts. What stays?
- Open this page in a second tab and mark a favorite there. What do the examples in the first tab show?
- Type a note in the example with the draft, restart the preview and then open the page in a new tab. Where is the note?
- In the first example, write the value
{into the entrylorraine-commons-favoritesunder "Application" in the developer tools and restart the preview. What does the page show?
Exercises
Section titled “Exercises”The exercises build the three wishes for the favorites and the form. The running case app shows the login. You'll find the project in the course repo in the folder exercises/07-state/persistence/en. Copy the folder, install the packages and start the tests.
npm installnpm testExercise Getting started
The store favorites forgets the favorites on a reload. Read them from localStorage under the key lorraine-commons-favorites when the store is created, and write every change back as JSON. A broken entry counts as none.
Solution
const KEY = 'lorraine-commons-favorites'
// The storage only holds text. A damaged entry counts as no favorites.function read() { try { const value = JSON.parse(localStorage.getItem(KEY)) return Array.isArray(value) ? value : [] } catch { return [] }}
export const useFavoritesStore = defineStore('favorites', () => { const ids = ref(read()) const count = computed(() => ids.value.length)
// Every change goes into the storage, the next start reads it back watch(ids, (value) => localStorage.setItem(KEY, JSON.stringify(value)), { deep: true })JSON.parse(null) gives null, and Array.isArray then lets the empty list apply, as for a broken entry. deep: true is needed in case someone changes the list with push.
Exercise Going further
A second tab changes the favorites, and the first one doesn't notice. Let the store listen to the storage event and read the favorites again when its key changes.
Solution
// Another tab of the same origin changed the favorites, this tab follows window.addEventListener('storage', (event) => { if (event.key === KEY) ids.value = read() })A store lives as long as the app runs, so it doesn't have to remove the listener again. In a composable for components, onUnmounted or onScopeDispose belongs with it, as in the composable of the running case app.
Exercise Challenge
The page of a thing keeps a draft of the form in sessionStorage under lorraine-commons-draft- followed by the number of the thing. After a reload it is back in the form, and after sending it is gone.
Solution
// The draft of each thing belongs to this tab only and ends when the tab closesconst key = computed(() => `lorraine-commons-draft-${props.id}`)
function readDraft() { try { return { from: '', to: '', note: '', ...JSON.parse(sessionStorage.getItem(key.value)) } } catch { return { from: '', to: '', note: '' } }}
const form = reactive(readDraft())const sent = ref(false)
// The router keeps the page when only the id changes, the form then takes the draft of the new thingwatch(key, () => Object.assign(form, readDraft()))
// Every change goes into the draft, an empty form removes itwatch( form, (value) => { if (Object.values(value).every((field) => field === '')) sessionStorage.removeItem(key.value) else sessionStorage.setItem(key.value, JSON.stringify(value)) }, { deep: true },)The router keeps the page when only the number changes, as in chapter 6.2. The watcher on key then fetches the draft of the new thing.
Common mistakes
Section titled “Common mistakes”| Mistake | What helps |
|---|---|
[object Object] comes out of the storage. | The storage only takes text. Store values with JSON.stringify and read them with JSON.parse. |
| A broken entry makes the app crash on start. | Put JSON.parse into try and catch, and check the shape of the value. |
| One tab doesn't notice the changes of another. | Listen to the storage event. The tab that writes doesn't get it. |
A password or a session identifier sits in localStorage. | Never. A login belongs in a cookie with HttpOnly that the server sets. |
An AI assistant stores the whole member in localStorage to stay signed in after a reload. | The server knows who is signed in. The app asks it after a reload. |
Self-check
Section titled “Self-check”Self-check
- How do
localStorageandsessionStoragediffer? - When does the browser fire the
storageevent, and when doesn't it? - Why doesn't a session identifier belong in the browser's storage?
- Why does the guard wait for the course API after a reload?
Answers
- All tabs of an origin share
localStorage, and it survives closing the browser.sessionStoragebelongs to one tab and ends with it. - In the other tabs of the same origin, when a tab changes an entry. The tab that writes doesn't get it.
- Every script of the page can read the storage, and a single cross-site scripting hole is enough to steal it. A cookie with
HttpOnlystays invisible to scripts. - After a reload the store is empty, and only the answer to
GET /sessiontells who is signed in. Without waiting, a signed-in team member would end up on the login page.
Assignment
The app keeps what it should keep and forgets what it must not keep. Martina Hostettler wants to show it to the association as soon as it runs publicly, and that comes with part 8. With this chapter the material of the exam is complete, and the competence grid shows you where you stand. If you want to know how libraries cache data from the server, read the deep dive on server state libraries.
Text and figures are licensed under CC BY-SA 4.0, the code under the MIT License and the course data under CC0 1.0. The figures use the font Kalam under the SIL Open Font License 1.1. The sources are on GitHub.