Restore after reload
A wizard that loses everything when someone reloads the page is a wizard people restart in a
worse mood. The persist plugin writes each commit to storage and reads it back when the wizard is created.
Type a name below, press Next, then reload this page.
The storage key and the application version live beside the flow they belong to.
import { defineFlow, step } from '@wizzard-packages/core';
/** One key per flow. Change it and every stored session is orphaned. */export const STORAGE_KEY = 'restore-after-reload';
/** * Where each rendered binding keeps its session. The page mounts the React and * the Vue wizard at once; under one key the one left idle in its tab would * restore the other's session and later write its own, older one over it. An * application renders one wizard per flow and needs only `STORAGE_KEY`. */export const storageKey = (binding: 'react' | 'vue'): string => `${STORAGE_KEY}-${binding}`;
/** * The application's own version, which is not the flow's. Bump it when the * meaning of what you collected changes, and sessions written before the change * are refused rather than half-read. */export const APP_VERSION = 1;
export const signup = defineFlow({ id: 'restore-after-reload', version: 1, order: ['name', 'colour', 'done'], steps: { name: step<{ full: string }>({ label: 'Your name' }), colour: step<{ favourite: string }>({ label: 'A colour' }), done: step({ label: 'Done' }), },});The plugin is installed in the browser only, and what it restored is said after the first paint.
import { persist, type RestoreOutcome } from '@wizzard-packages/plugins/persist';import { WizardProvider, useField, useNavigation, useStep } from '@wizzard-packages/react';import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
import { APP_VERSION, signup, storageKey } from './flow';import { describeRestore } from './outcome';
export function App(): ReactNode { // What the plugin decided, kept out of React's state until it is safe to // read: `onRestore` runs while the engine is being created, which is during a // render, and setting state there is not allowed. const restored = useRef<RestoreOutcome | null>(null);
// In the browser only. On a server there is no storage to read, and a session // restored into the server's markup is a hydration mismatch waiting to happen. const plugins = useMemo( () => typeof window === 'undefined' ? [] : [ persist({ key: storageKey('react'), version: APP_VERSION, onRestore: (outcome) => { restored.current = outcome; }, }), ], [] );
return ( <WizardProvider flow={signup} plugins={plugins}> <Wizard restored={restored} /> </WizardProvider> );}
export function Wizard(props: { restored: { current: RestoreOutcome | null } }): ReactNode { const { current, isLast } = useStep(); const { next, back, canBack } = useNavigation(); const [full, setFull] = useField<string>('name.full'); const [favourite, setFavourite] = useField<string>('colour.favourite');
// Said after the first paint, for the same reason the plugin is installed in // the browser only: the server never saw this session. const [outcome, setOutcome] = useState<string>('Starting.'); useEffect(() => { setOutcome(describeRestore(props.restored.current)); }, [props.restored]);
const step = current ?? 'name'; const starting = current === null;
return ( <form onSubmit={(e) => e.preventDefault()}> <p role="status">{outcome}</p>
{step === 'name' && ( <label> Your name <input value={full ?? ''} onChange={(e) => setFull(e.target.value)} disabled={starting} /> </label> )} {step === 'colour' && ( <label> A colour <input value={favourite ?? ''} onChange={(e) => setFavourite(e.target.value)} disabled={starting} /> </label> )} {step === 'done' && <p>Saved as you went. Reload the page and see.</p>}
<button type="button" onClick={() => back()} disabled={starting || !canBack}> Back </button> <button type="button" onClick={() => next()} disabled={starting || isLast}> Next </button> </form> );}The reason turned into a sentence. Only the host knows what the form is called.
import type { RestoreOutcome } from '@wizzard-packages/plugins/persist';
/** * What the person is told about the session they did or did not get back. * * A form that was half filled in and is now empty looks like a bug to whoever * filled it, and silence is why they would think so. The plugin answers with a * reason for exactly this; turning it into a sentence is the host's job, * because only the host knows what the form is called. * * Shared by both renderings so the two cannot drift into saying different * things about the same event. */export function describeRestore(outcome: RestoreOutcome | null): string { if (outcome === null) return 'Starting.'; if (outcome.restored) return 'Restored. You are back where you left off.';
switch (outcome.reason) { case 'persist/nothing-stored': return 'Nothing saved yet. This one will be, from the first answer.'; case 'persist/unavailable': return 'Saving unavailable: this browser did not allow storage, which private windows do.'; case 'snapshot/version': return 'Reset: the saved session is in a format this build does not read.'; default: return `Reset: the saved session could not be read (${outcome.reason}).`; }}import { defineFlow, step } from '@wizzard-packages/core';
/** One key per flow. Change it and every stored session is orphaned. */export const STORAGE_KEY = 'restore-after-reload';
/** * Where each rendered binding keeps its session. The page mounts the React and * the Vue wizard at once; under one key the one left idle in its tab would * restore the other's session and later write its own, older one over it. An * application renders one wizard per flow and needs only `STORAGE_KEY`. */export const storageKey = (binding: 'react' | 'vue'): string => `${STORAGE_KEY}-${binding}`;
/** * The application's own version, which is not the flow's. Bump it when the * meaning of what you collected changes, and sessions written before the change * are refused rather than half-read. */export const APP_VERSION = 1;
export const signup = defineFlow({ id: 'restore-after-reload', version: 1, order: ['name', 'colour', 'done'], steps: { name: step<{ full: string }>({ label: 'Your name' }), colour: step<{ favourite: string }>({ label: 'A colour' }), done: step({ label: 'Done' }), },});<script setup lang="ts">import { persist, type RestoreOutcome } from '@wizzard-packages/plugins/persist';import { provideWizard } from '@wizzard-packages/vue';
import Wizard from './Wizard.vue';import { APP_VERSION, signup, storageKey } from './flow';
/** * The plugin is installed in the browser only: on a server there is no storage * to read, and a session restored into the server's markup is a hydration * mismatch waiting to happen. What it restored is shown by the child, one tick * after mount, for the same reason. */const restored: { outcome: RestoreOutcome | null } = { outcome: null };
const plugins = typeof window === 'undefined' ? [] : [ persist({ key: storageKey('vue'), version: APP_VERSION, onRestore: (outcome) => { restored.outcome = outcome; }, }), ];
provideWizard({ flow: signup, plugins });</script>
<template> <Wizard :restored="restored" /></template><script setup lang="ts">import type { RestoreOutcome } from '@wizzard-packages/plugins/persist';import { useField, useNavigation, useStep } from '@wizzard-packages/vue';import { computed, onMounted, ref } from 'vue';
import { describeRestore } from './outcome';
const props = defineProps<{ restored: { outcome: RestoreOutcome | null } }>();
const { current, isLast } = useStep();const { next, back, canBack } = useNavigation();const full = useField<string>('name.full');const favourite = useField<string>('colour.favourite');
// Said after the first paint, for the same reason the plugin is installed in// the browser only: the server never saw this session.const outcome = ref('Starting.');onMounted(() => { outcome.value = describeRestore(props.restored.outcome);});
const step = computed(() => current.value ?? 'name');const starting = computed(() => current.value === null);</script>
<template> <form @submit.prevent> <p role="status">{{ outcome }}</p>
<label v-if="step === 'name'"> Your name <input v-model="full" :disabled="starting" /> </label> <label v-else-if="step === 'colour'"> A colour <input v-model="favourite" :disabled="starting" /> </label> <p v-else-if="step === 'done'">Saved as you went. Reload the page and see.</p>
<button type="button" :disabled="starting || !canBack" @click="back()">Back</button> <button type="button" :disabled="starting || isLast" @click="next()">Next</button> </form></template>import type { RestoreOutcome } from '@wizzard-packages/plugins/persist';
/** * What the person is told about the session they did or did not get back. * * A form that was half filled in and is now empty looks like a bug to whoever * filled it, and silence is why they would think so. The plugin answers with a * reason for exactly this; turning it into a sentence is the host's job, * because only the host knows what the form is called. * * Shared by both renderings so the two cannot drift into saying different * things about the same event. */export function describeRestore(outcome: RestoreOutcome | null): string { if (outcome === null) return 'Starting.'; if (outcome.restored) return 'Restored. You are back where you left off.';
switch (outcome.reason) { case 'persist/nothing-stored': return 'Nothing saved yet. This one will be, from the first answer.'; case 'persist/unavailable': return 'Saving unavailable: this browser did not allow storage, which private windows do.'; case 'snapshot/version': return 'Reset: the saved session is in a format this build does not read.'; default: return `Reset: the saved session could not be read (${outcome.reason}).`; }}import { defineFlow, step } from '@wizzard-packages/core';
/** One key per flow. Change it and every stored session is orphaned. */export const STORAGE_KEY = 'restore-after-reload';
/** * Where each rendered binding keeps its session. The page mounts the React and * the Vue wizard at once; under one key the one left idle in its tab would * restore the other's session and later write its own, older one over it. An * application renders one wizard per flow and needs only `STORAGE_KEY`. */export const storageKey = (binding: 'react' | 'vue'): string => `${STORAGE_KEY}-${binding}`;
/** * The application's own version, which is not the flow's. Bump it when the * meaning of what you collected changes, and sessions written before the change * are refused rather than half-read. */export const APP_VERSION = 1;
export const signup = defineFlow({ id: 'restore-after-reload', version: 1, order: ['name', 'colour', 'done'], steps: { name: step<{ full: string }>({ label: 'Your name' }), colour: step<{ favourite: string }>({ label: 'A colour' }), done: step({ label: 'Done' }), },});A reload without a browser: a `Map` behind the three storage methods, and a second wizard over the same bytes.
import { createWizard } from '@wizzard-packages/core';import { persist, type RestoreOutcome, type SyncStorage } from '@wizzard-packages/plugins/persist';
import { APP_VERSION, STORAGE_KEY, signup } from './flow';import { describeRestore } from './outcome';
/** * A reload, without a browser to reload. * * The plugin writes to `localStorage` unless it is handed something else, and * what it needs is two methods. A `Map` behind them is enough to show the * whole round trip: fill a session in, throw the wizard away, build another * one over the same storage, and read what came back. */const kept = new Map<string, string>();
const storage: SyncStorage = { getItem: (key) => kept.get(key) ?? null, setItem: (key, value) => { kept.set(key, value); },};
const open = async (): Promise<{ wizard: ReturnType<typeof createWizard>; said: string }> => { let outcome: RestoreOutcome | null = null; const wizard = createWizard({ flow: signup, plugins: [ persist({ key: STORAGE_KEY, version: APP_VERSION, storage, onRestore: (o) => { outcome = o; }, }), ], }); await wizard.start(); return { wizard, said: describeRestore(outcome) };};
const first = await open();console.log(`first visit: ${first.said}`);first.wizard.set('name.full', 'Ada');await first.wizard.next();console.log(`left on: ${String(first.wizard.getSnapshot().current)}`);
// The tab closes here. Writes are coalesced - one per frame, not one per// keystroke - so the plugin flushes what is pending when it is torn down, which// is what `destroy()` does and what a browser's `pagehide` does for it. Without// this, the last answer would still be waiting on a timer nobody will see.first.wizard.destroy();
// Nothing of the wizard above survives except the bytes.const second = await open();console.log(`after reload: ${second.said}`);console.log(`back on: ${String(second.wizard.getSnapshot().current)}`);console.log(`name kept: ${String(second.wizard.get('name.full'))}`);A test in the repository asserts these exact lines.
first visit: Nothing saved yet. This one will be, from the first answer.left on: colourafter reload: Restored. You are back where you left off.back on: colourname kept: AdaThree things this gets right
Section titled “Three things this gets right”It never throws. A browser that refuses storage, a quota that fills up, a session written before the flow changed shape: each one means this session is not coming back, and none of them is a reason to break the wizard someone is filling in right now.
It says what happened. onRestore hands you { restored: true } or a reason, and the
example turns that into a sentence. A form that was half filled in and is now empty looks like a
bug to whoever filled it, and silence is why they would think so.
It writes once per frame, not once per keystroke. A pending write is flushed when the page hides and when the wizard is destroyed, so the last answer is not lost to a timer nobody waited for.
This one is mounted in the browser only
Section titled “This one is mounted in the browser only”A wizard is created with its plugins already in place: the engine initialises them before it
hands itself out, so by the time anything renders, persist has read storage and committed what
it found. That is what makes a restored session available on the first frame instead of the
second, and it is also why this example is not rendered on the server.
Follow it through with a stored session. The server has no storage, so it renders the first step. The browser restores the third and renders that. The two do not match, the framework throws its markup away and starts again, and the person watching sees the first step flash past. Rendering nothing on the server is the honest answer here, not a workaround: there is no correct server rendering of a session the server cannot see.
Both renderings above are mounted with Astro’s client:only for that reason. Every framework
spells the same instruction differently - dynamic(() => import('./App'), { ssr: false }) in
Next.js, <ClientOnly> in Nuxt - and what it means in each is that this subtree belongs to the
browser.
The typeof window === 'undefined' guard in the sources stays regardless. It is what keeps the
plugin from being built at all where there is no storage to build it against, and your own
application may reach that code by a path this page does not.
Where to read more
Section titled “Where to read more”Persistence is the format underneath: what a snapshot carries, which stored
sessions are refused and why, and the migrate hook for the ones you would rather upgrade than
discard.