Saving state with useStorage
Keep a ref in sync with localStorage so a note or a setting survives a reload, and add new fields without breaking old visitors.
What you'll learn
When you finish, you can do these.- 1Keep a note through a reload with useStorage()
- 2Choose a default so saved numbers stay numbers
- 3Clear a saved value and pick local or session storage
- 4Fix a missing new field and a server-rendering mismatch
Picture a small note app: a text box for a note, and a Compact checkbox that makes the box shorter. Type a shopping list, tick Compact, reload the page, and both are gone. The browser can keep small text values in localStorage, each under a name you pick, its key. By hand, that takes three jobs: read it on load, write it on every change, and turn objects into text and back.
VueUse, a library of ready-made functions for Vue, has one that does all three: useStorage(). You need one idea from the lesson Meet VueUse: a VueUse function called in <script setup> hands you refs, as useMouse() handed over the mouse position. A ref is a box that holds a value and tells Vue when it changes. The Code tab holds the note app, App.vue, already saving with useStorage(). By the end you can read every line of it, clear the note, and add a setting without breaking what visitors saved.
A ref that remembers
useStorage(key, default) returns a ref that keeps itself in localStorage. When it is created, it reads storage under key. If nothing is stored yet, it writes the default there and uses that. After that, every change to the ref is written back.
The script of App.vue makes two such refs, one for the note and one for the settings:
const note = useStorage('demo-note', '')
const prefs = useStorage('demo-prefs', { compact: false })
In the template, v-model, which keeps an input and a ref in step, ties the text box to note and the checkbox to prefs.compact. A counter line shows the number of characters and the layout, compact or roomy. Type a note, tick Compact, then edit any line of code: the preview restarts, which works like a reload here, and both come back.
Storage only holds text, so each value is turned into text and back, which is called serializing. The type of the default picks how. A string default, like the note's empty string, stores the text as it is. A number or boolean default reads "3" back as 3 and "true" as true. An object or array default goes through JSON, a standard way to write objects and arrays as text.
For an object default, VueUse also watches every field inside it, so changing one field is saved too. Ticking the checkbox runs prefs.value.compact = true, and storage then holds {"compact":true}.
- useStorage(key, default)
- read storage or default
- ref changes
- write as text
- storage eventother tabs
The diagram's last step keeps tabs in step: when one tab writes to localStorage, the browser sends every other open tab of the site a storage event, and useStorage() listens for it. Open this lesson in two tabs and type in one: the other note follows.
Clear it, scope it, keep it small
Once the ref works, three choices remain: how to delete a saved value, which storage keeps it, and what belongs there.
Setting the ref to null removes the key from storage, and the ref goes back to the default straight away. So note.value = null leaves an empty note, not a null that would break the counter's note.length.
useStorage() can also take the storage as a third argument, and options as a fourth. useLocalStorage() and useSessionStorage() are useStorage() with the storage chosen for you, so their options go third. Session storage belongs to one tab and empties when it closes, which suits a half-filled form that should not follow the person to a new tab.
useStorage() suits small values: a draft, a theme, a dismissed banner. Any script on the page, and anyone at the device, can read storage, so never keep a password or a login token there.
What goes wrong
Both mistakes hit only visitors who saved something on an earlier visit, so a test with empty storage misses them.
The first shows up weeks after release. Version one of the note app saved prefs with one field, compact. A later version adds a font size: the default gains fontSize: 16, and the text box and the counter line use prefs.fontSize + 'px'.
const prefs = useStorage('demo-prefs', { compact: false, fontSize: 16 })
New visitors get 16. Returning visitors already have an object such as {"compact":false} stored, and the stored object wins, with no fontSize in it. The counter line shows undefinedpx, and the browser ignores that size: VueUse uses the default only when the key is missing.
The mergeDefaults option fills each field the stored object lacks from the default and keeps the stored values for the rest. The fix switches to useLocalStorage(), where options go third:
const prefs = useLocalStorage('demo-prefs', { compact: false, fontSize: 16 }, { mergeDefaults: true })
Returning visitors keep their Compact choice and get the size 16. The merge fills only the object's own fields, not those of an object inside it.
The second appears with server rendering, where a server builds the page's HTML first, as Nuxt, a framework built on Vue, does. The server has no localStorage, so its counter line shows the default: 0 characters · roomy layout. For a visitor who ticked Compact earlier, with no note, the browser reads the stored value while it hydrates the page (attaches Vue to the server's HTML), and gets 0 characters · compact layout.
The two disagree, and in development the console shows [Vue warn]: Hydration text content mismatch on and the counter's <p>, with rendered on server: 0 characters · roomy layout and expected on client: 0 characters · compact layout under it. Vue corrects the text, so the page jumps after load. The cause is the starter's own line:
const prefs = useStorage('demo-prefs', { compact: false })
The initOnMounted option, passed the same way, makes the browser start from the default as well, so its first render matches the server HTML, and it reads storage once the component is mounted:
const prefs = useLocalStorage('demo-prefs', { compact: false }, { initOnMounted: true })
The warning is gone, but the page still switches to compact a moment after load. Only a cookie, which the server can read, avoids that switch.
A saved note would cause the same warning, so the note's line needs initOnMounted too.
What to remember
useStorage(key, default)returns a ref that starts from storage, or from the default when the key is missing, and saves every change, nested fields included.- The type of the default decides how the value is turned into text and back.
- Setting the ref to
nulldeletes the key and puts the default back.useSessionStorage()keeps a value for one tab only. - Fields added to an object default later are missing for returning visitors unless you pass
mergeDefaults: true. - With server rendering,
initOnMounted: truemakes the first render match the server, and the stored value arrives after mount.
Try it
Open the Code tab and change the starter code. Each task says what you should see.- Add
<button @click="note = null">Clear</button>under the counter. Type a note and click Clear: the box empties and the counter says 0 characters. Edit any line so the preview restarts, and the note is still empty, because Clear deleted the saved note. - Add
const goal = useStorage('demo-goal', 100)and<p>Goal: {{ goal }} characters <button @click="goal = goal + 50">+50</button></p>. Click +50 twice and restart the preview: the goal is still 200. Now change the default to'100', with quotes, and click +50 once: the goal reads 20050, because a string default reads the stored 200 back as text. - Add
fontSize: 16to the prefs default,:style="{ fontSize: prefs.fontSize + 'px' }"on the textarea, and· {{ prefs.fontSize + 'px' }}to the counter line. It shows undefinedpx: the object saved by the starter has nofontSize. Switch touseLocalStorage('demo-prefs', { compact: false, fontSize: 16 }, { mergeDefaults: true }), imported from@vueuse/core, and the line shows 16px.