Change a stored shape
A localStorage row or a cookie outlives the code that wrote it: after the schema changes, rows the old code wrote still come in to be read. This page covers how an old row reads when you do nothing, and how version and migrate carry rows across a change of shape.
On this page
Left alone, rows are salvaged field by field
A row an older schema wrote is read field by field, like any other input: every field the current schema accepts keeps its value, and the rest land on their defaults.
- A field was added: it starts from its default and everything else stays. That is right.
- A constraint was tightened: only the values that no longer fit fall back. That is right too.
- A field was renamed: the value under the old name is never read, and the new field silently starts from its default.
- A value changed meaning: the old value is read with the new meaning, or silently resets when it no longer fits.
For changes salvage cannot carry, like the last two, use version and migrate.
Pass version and migrate
defineLocalState and defineCookieState take version and migrate as their third argument.
prefs.tsexport const prefs = defineLocalState(
'prefs',
z.object({
view: z.enum(['grid', 'table']).default('grid'),
pageSize: z.number().default(20),
}),
{
version: 1,
migrate: (old) => ({
view: old['layout'] === 'list' ? 'table' : 'grid',
pageSize: old['pageSize'],
}),
},
);In this example, rows written before the version existed held { layout: 'list' | 'cards' }, and migrate turns that layout into today’s view.
A versioned row is stored as [version, values], and a row with no version reads as version 0. So declaring version: 1 when the shape first changes migrates the existing rows from 0.
localStorage: k8ordo-state:prefs{"layout":"list","pageSize":50}
A row from before the version
[1,{"view":"table","pageSize":50}]
The row after migration and write-backversion is a positive integer. Anything else makes the definition throw: "prefs" version must be a positive integer, got 0.
How an older row is read
A row older than version is read in three steps.
migrate(old, fromVersion)turns the old values into the current shape.fromVersionis the version the row was written with.- What it returns passes the schema field by field, like any read.
- The browser store writes the result back in the current version.
migrate returns the schema’s keys; another key is a type error. Hand old values over as they are, and the schema rejects whatever does not fit.
On the server, parseCookies migrates too, but never writes: a page cannot answer with Set-Cookie. The browser writes back after hydration, and both read the same values, so nothing flashes.
The next change
On the next change, raise the version and branch on migrate’s second argument, fromVersion.
prefs.tsexport const prefs = defineLocalState(
'prefs',
z.object({
view: z.enum(['grid', 'table']).default('grid'),
perPage: z.number().default(20),
}),
{
version: 2,
migrate: (old, fromVersion) => {
if (fromVersion === 0) {
return {
view: old['layout'] === 'list' ? 'table' : 'grid',
perPage: old['pageSize'],
};
}
return { view: old['view'], perPage: old['pageSize'] };
},
},
);Here version 2 renames pageSize to perPage. Rows from version 0 and version 1 both reach the current shape in one migrate.
What to watch for
A few cases come with versioned rows.
- A newer row: one a tab that loaded the next deploy first has written. It is salvaged without
migrateand never written back, since it belongs to that newer tab. - A throwing
migrate: it reads as nothing stored, with the defaults showing, and the row stays as it was for a fixedmigrateto try again. - Adopting a version: the row changes shape to
[version, values]. A tab still running the code from before reads it as nothing stored until it reloads. inlineRead(): it hands out only the current version. Neither the schema normigrateruns before hydration, so a row of any other version isnulluntil a store has migrated it.defineSessionState: it takes no version. Its rows go with the tab, and salvage covers the rare one kept open across a deploy.
Without the option nothing changes: a row is the bare values object, salvaged field by field.