You will build a settings panel for a theme and a font size whose choices survive a reload and a browser restart, with one useStorage() call doing the saving. The rule to learn before you ship it: a value already in storage always beats the default in your code.
Why a ref forgets on reload
Here is a theme switch the way most people first write it:
<script setup lang="ts">
import { ref } from 'vue'
const theme = ref<'light' | 'dark'>('light')
</script>
<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
Theme: {{ theme }}
</button>
</template>
It works until the user presses reload. A ref lives in memory, and memory is cleared with the page.
To keep a value, the browser offers localStorage: a small key-value store, strings only, that belongs to your site and keeps its data when the browser closes. Wiring a ref to it by hand means reading the key on start, parsing it, watching the ref, and writing on every change. useStorage() is that wiring, done once and tested.
Replace the ref with useStorage()
Install VueUse if the project does not have it yet. Version 12.7 is current:
npm install @vueuse/core
Then replace the ref with useStorage. The first argument is the storage key, the second the value to use when nothing is stored yet:
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
const theme = useStorage<'light' | 'dark'>('theme', 'light')
</script>
<template>
<button @click="theme = theme === 'light' ? 'dark' : 'light'">
Theme: {{ theme }}
</button>
</template>
Nothing else changes. theme is still a ref. You read theme.value in the script and theme in the template, and you assign to it as before.
Behind the scenes, useStorage reads localStorage.getItem('theme') when the component is set up:
- If the key exists, the ref starts with the stored value.
- If it does not, the ref starts with
'light', and that default is written to storage straight away. - From then on, every change to the ref is written back.
The serialiser (the code that turns a value into a string and back) is picked from the type of the default. A string default is stored as is, a number is written as text and parsed back into a number, a boolean becomes 'true' or 'false', and an object or array goes through JSON. Date, Map and Set are handled too. For anything else, the useStorage() docs show how to pass your own serializer.
Store a whole settings object
A settings panel usually has more than one value. Pass an object as the default and useStorage stores the whole thing as JSON under one key. Changes to a nested property are saved too, because the ref is watched deeply by default.
<script setup lang="ts">
import { useStorage } from '@vueuse/core'
const settings = useStorage('settings', {
theme: 'light' as 'light' | 'dark',
fontSize: 16,
})
</script>
<template>
<section :class="settings.theme" :style="{ fontSize: `${settings.fontSize}px` }">
<label>
Theme
<select v-model="settings.theme">
<option value="light">Light</option>
<option value="dark">Dark</option>
</select>
</label>
<label>
Font size: {{ settings.fontSize }}px
<input v-model.number="settings.fontSize" type="range" min="12" max="24" />
</label>
<p>This text follows your settings, and will again after a reload.</p>
</section>
</template>
Change the font size, reload, and the slider is where you left it. Two lines are worth explaining:
v-model.numberon the range input turns its value into a number. Without it the input hands you the string'18', which is saved as a string while TypeScript still calls it a number, sosettings.fontSize + 2gives'182'.- TypeScript infers the type of
settingsfrom the default, which is whythemeis written withas 'light' | 'dark'. Without it,themewould accept any string.
Why a changed default does not reach existing users
Suppose the panel has shipped with fontSize: 16 and you decide the default should be 18. You change the code, reload, and still see 16. Nothing is broken: your browser has a settings key from the earlier version, and a stored value always beats the default.
New users get 18. Everyone who opened the page before gets whatever they had, including people who never touched the slider, because useStorage wrote the old default on their first visit.
In production, treat a stored default as the user's choice, because to them it is. If you genuinely need to reset a user's value, assign null to the ref: settings.value = null removes the key, and the next page load starts from the default again.
Add a new setting with mergeDefaults
The same rule catches you when the object grows. Three months later you add a density option. Users who already have a settings key load { theme, fontSize } without density, and settings.value.density comes back undefined.
The mergeDefaults option fixes this. With true, the default and the stored object are merged shallowly, so new top-level keys get their default and existing ones keep the stored value.
Options are the fourth argument of useStorage(), after the storage object. useLocalStorage() is useStorage() with localStorage already chosen, so its options come third. The panel's call becomes:
import { useLocalStorage } from '@vueuse/core'
const settings = useLocalStorage(
'settings',
{ theme: 'light', fontSize: 16, density: 'comfortable' },
{ mergeDefaults: true },
)
Use useLocalStorage() whenever you need options. Passing localStorage to useStorage() by hand breaks on a server, where that name does not exist.
A shallow merge does not reach inside nested objects. For those, pass a function instead of true and merge however you like: mergeDefaults: (stored, defaults) => ({ ...defaults, ...stored }) is the shallow version written out, and you can go deeper from there. Turn mergeDefaults on for any object you expect to grow.
Session storage and syncing between tabs
useSessionStorage() has the same signature as useLocalStorage() and uses sessionStorage, which is cleared when the tab closes. It is the right choice for a draft of a form or a wizard step that should not come back tomorrow:
import { useSessionStorage } from '@vueuse/core'
const draft = useSessionStorage('contact-draft', { name: '', message: '' })
With localStorage, the browser fires a storage event in every other open tab of your site when a value changes, and useStorage listens for it. Change the theme in one tab and the other tabs follow without a reload. That is on by default; set listenToStorageChanges: false if two tabs should be allowed to disagree.
Avoid hydration mismatches with initOnMounted
On the server there is no localStorage, so during server-side rendering useStorage returns a ref holding the default, and the HTML is rendered with it. In the browser, by default, the stored value is read during setup, before hydration (Vue taking over the server's HTML) has finished. If the user had chosen dark, the server sent light and the client immediately wants dark.
Vue reports this as a hydration mismatch, and it only partly repairs it. Text is updated to the client's value, but attributes such as class and style keep what the server sent. A user who saved dark and 20px in the settings panel sees the label "Font size: 20px", but the text is still 16px and the theme still light.
The initOnMounted option moves the read to after the component is mounted:
import { useLocalStorage } from '@vueuse/core'
const theme = useLocalStorage('theme', 'light', { initOnMounted: true })
Hydration then matches, and the stored value is applied a moment later. In a Nuxt app, or any app that renders on the server, set it on every storage ref that affects what is rendered. In a plain Vite app there is no server render and you can leave it off.
In Nuxt, import useStorage() from @vueuse/core yourself, as these examples do. The @vueuse/nuxt module leaves it out of its auto-imports, because Nitro, Nuxt's server engine, has a useStorage() of its own.
What not to store in localStorage
Settings, drafts, a dismissed banner and a last-visited tab are a good fit. Keep these out:
- Secrets and tokens.
localStorageis readable by any script that runs on your page, including a third-party widget or an injected one. Keep tokens in anHttpOnlycookie set by the server, which scripts cannot read. - Large data. Browsers cap it at a few megabytes per site, reads and writes are synchronous, and a large JSON blob is parsed on every page load.
- Anything the user expects to keep or to see on another device. The browser never sends
localStorageto the server, so it stays in this one browser. Clearing the browser wipes it, and Safari deletes it after seven days of Safari use without the user interacting with your site. Data a user would be upset to lose belongs in a database.