Skip to content

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:

  1. Favorites survive a reload and are the same in every tab.
  2. The login survives a reload and ends when the browser closes.
  3. A request someone started stays in its tab, even after a reload.

Learning goals

After this chapter you can

  • tell localStorage from sessionStorage and store values as JSON,
  • connect a store to the browser's storage,
  • keep several tabs in step with the storage event,
  • explain what never belongs in the browser's storage,
  • ask the server about a login after a reload.

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.

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

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

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

main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

things.js

things.js
// A few things of the catalog
export 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.

favorites.js
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 }
})

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

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

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

main.js
import { createPinia } from 'pinia'
import { createApp } from 'vue'
import App from './App.vue'
createApp(App).use(createPinia()).mount('#app')

things.js

things.js
// A few things of the catalog
export 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 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

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 closes
function 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 it
watch(
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

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.

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.

How long data stays in the browser Four bars across six events: load page, reload, second tab, close tab, close browser and open browser. A store in memory lasts until the reload. sessionStorage survives the reload, belongs to this tab only and ends when it closes. A cookie without expiry applies to all tabs and ends when the browser closes. localStorage applies to all tabs and is still there after the next start. load page reload second tab close tab close browser open browser Store in memory until the reload sessionStorage this tab only until the tab closes Cookie without expiry all tabs localStorage all tabs stays

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.

auth.js
// 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.

index.js
// 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 } }
})

Work with the examples of this chapter. Read the code and write down what you expect before you change anything.

  1. Mark two favorites in the first example and then change something in the code, so that the preview restarts. What stays?
  2. Open this page in a second tab and mark a favorite there. What do the examples in the first tab show?
  3. 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?
  4. In the first example, write the value { into the entry lorraine-commons-favorites under "Application" in the developer tools and restart the preview. What does the page show?

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.

Terminal window
npm install
npm test

Exercise 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
favorites.js
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
favorites.js
// 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
ItemView.vue
// The draft of each thing belongs to this tab only and ends when the tab closes
const 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 thing
watch(key, () => Object.assign(form, readDraft()))
// Every change goes into the draft, an empty form removes it
watch(
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.

MistakeWhat 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

  1. How do localStorage and sessionStorage differ?
  2. When does the browser fire the storage event, and when doesn't it?
  3. Why doesn't a session identifier belong in the browser's storage?
  4. Why does the guard wait for the course API after a reload?
Answers
  1. All tabs of an origin share localStorage, and it survives closing the browser. sessionStorage belongs to one tab and ends with it.
  2. In the other tabs of the same origin, when a tab changes an entry. The tab that writes doesn't get it.
  3. Every script of the page can read the storage, and a single cross-site scripting hole is enough to steal it. A cookie with HttpOnly stays invisible to scripts.
  4. After a reload the store is empty, and only the answer to GET /session tells 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.