With VueUse's useStorage persisting a dashboard filter to localStorage, why is a newly added filter field undefined for returning users?
answer
- stored value beats the default
- an object saved last release
- merge the defaults in
- shallow unless you pass a function
- serializer picked from the default
basics
~20 suseStorage uses the stored value whenever the key exists and ignores the default, so a filter object saved before the field existed lacks it. mergeDefaults: true shallow-merges the defaults under the stored object; a merge function handles nested fields.
solid answer
~40 s`useStorage('dashboard-filter', defaults)` returns a ref backed by `localStorage` by default. On first read, if the key is missing it returns the default and, because `writeDefaults` is true, writes it; if the key exists, it deserializes the stored string and **ignores the default**. A returning user's object was saved before `compare` existed, so `filter.value.compare` is `undefined`. Passing `mergeDefaults: true` spreads the defaults under the stored object — a shallow merge — and a function `(stored, defaults) => …` handles nested objects. Related behaviour worth knowing: the serializer is chosen from the default's type (JSON for objects), assigning `null` removes the key, other tabs stay in sync through the `storage` event because `listenToStorageChanges` defaults to true, and `initOnMounted` delays the read until mount to keep server-rendered markup and the first client render identical.
code
ts · 20 linesimport { useStorage } from '@vueuse/core'
export interface DashboardFilter {
range: '7d' | '30d' | '90d'
channel: string
compare: boolean // added in release 2
}
export function useDashboardFilter() {
return useStorage<DashboardFilter>(
'dashboard-filter',
{ range: '30d', channel: 'all', compare: false },
undefined, // default storage: localStorage
{ mergeDefaults: true, initOnMounted: true },
)
}
// const filter = useDashboardFilter()
// filter.value.compare -> false for users who saved before release 2
// filter.value = null -> removes the keygo deeper
Recall that useStorage returns a ref mirrored into localStorage, and that the stored value wins over the default when the key exists.
Explain mergeDefaults and its shallow merge, the serializer chosen from the default's type, and how null removes the key.
Plan persisted-state evolution: merge functions for nested fields, versioned keys for breaking shape changes, and initOnMounted for server-rendered pages.
Treat browser-persisted state as a data contract with old clients: every release must read what earlier releases wrote, or deliberately discard it.
## How useStorage works `useStorage(key, defaults, storage?, options?)` from `@vueuse/core` returns a ref whose value is mirrored into Web Storage: - **Storage:** `localStorage` unless a third argument such as `sessionStorage` is passed; `useLocalStorage` and `useSessionStorage` are shorthands. - **Reading:** on creation it reads the key and deserializes it; the key itself may be a ref or getter, and the ref re-reads when it changes. - **Writing:** a deep watcher on the ref serializes and writes every change; assigning `null` removes the key instead. - **Serialization:** chosen from the type of `defaults` — `JSON.stringify`/`JSON.parse` for objects and arrays, `parseFloat` for numbers, and dedicated serializers for booleans, `Map`, `Set` and `Date`, all exposed as `StorageSerializers`. - **Errors:** read and write failures go to `onError`, which logs with `console.error` by default. ## Why the new field is undefined In the analytics dashboard, release 1 persisted `{ range: '30d', channel: 'all' }`. Release 2 adds `compare: false` to the defaults. For a returning user: 1. The key `dashboard-filter` exists, holding `{"range":"7d","channel":"all"}`. 2. Because a stored value exists, `useStorage` deserializes it and returns it as is; the default object is used only when the key is **missing**. 3. The stored object has no `compare` property, so `filter.value.compare` is `undefined` — not `false` — and a checkbox bound to it or a request built from it misbehaves. 4. Nothing throws. New users, who get the full default written on first visit, never see the bug, which is why it tends to reach production. ## The fix: mergeDefaults | Option | Default | Effect | |---|---|---| | `mergeDefaults` | `false` | `true`: stored object is spread over the defaults (shallow); a function `(stored, defaults) => merged` for custom merging | | `writeDefaults` | `true` | writes the default when the key is missing | | `listenToStorageChanges` | `true` | updates the ref when another tab or another `useStorage` for the key writes | | `deep` | `true` | watches nested changes before writing | | `initOnMounted` | `false` | reads storage only after the component mounts | | `shallow` | `false` | uses `shallowRef` for the value | With `mergeDefaults: true`, missing top-level fields come from the defaults and stored ones win. The merge is **shallow**: if the filter nests an object such as `columns: { revenue: true }` and a new nested key is added, pass a merge function that deep-merges instead. A default of `null` gives `useStorage` no type to infer; pass a `serializer`, for example `StorageSerializers.object`. ## Syncing and server rendering - **Cross-tab:** with `listenToStorageChanges` on, a change in another tab arrives through the browser's `storage` event and updates the ref. `useStorage` also dispatches a synthetic event on its own window so that two instances for the same key in one page stay in sync. - **Server rendering:** on the server there is no `localStorage`, so the ref simply holds the default. In the browser the stored value would then differ from the server-rendered markup; `initOnMounted: true` makes the first client render use the default too and applies the stored value after mount, avoiding a hydration mismatch. ## Other traps - **Passing `localStorage` explicitly** as the third argument evaluates the global at call time and fails where it does not exist; leaving it `undefined` lets VueUse pick the default storage safely. - **Treating the ref as a schema migration.** `mergeDefaults` fills gaps but does not rename or retype fields; version the key (for example `dashboard-filter:v2`) when the shape changes incompatibly. - **Storing large, frequently changing objects.** Every change is serialized and written synchronously; keep persisted state small. ## Reproducing the bug in a test Because new users never hit it, the undefined-field bug needs a deliberate test: 1. Before mounting, write the **old shape** under the key: `localStorage.setItem('dashboard-filter', JSON.stringify({ range: '7d', channel: 'all' }))`. 2. Mount the component or call the composable. 3. Assert that `filter.value.compare` is `false` and `filter.value.range` is still `'7d'` — the stored choice must survive the merge. 4. Clear storage between tests, since `writeDefaults` writes the default on the first read of an empty key and the next test would otherwise start from it. Keeping one such fixture per released shape turns "every release must read what earlier releases wrote" into a check that runs in CI.
- When does VueUse's mergeDefaults: true still leave a field undefined?When the missing field is nested. The built-in merge spreads the stored object over the defaults at the top level only, so a new key inside a nested object stays missing if the stored object already has that nested object. Pass a function that deep-merges instead.
- How do two dashboard tabs stay in sync with VueUse's useStorage?`listenToStorageChanges` defaults to true, so each instance listens to the window's `storage` event. When one tab writes the key, the other tab receives the event and updates its ref. Within one page, VueUse dispatches its own event so instances for the same key also agree.
saying these in an interview costs you the question
- useStorage merges the default object into the stored value automatically.
- mergeDefaults: true deep-merges nested objects without extra code.
- Assigning null to a useStorage ref stores the string 'null'.
- useStorage always serializes with JSON whatever the default's type.
- A useStorage ref does not notice writes from another tab.