Skip to content

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.

flow.ts
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.

App.tsx
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.

outcome.ts
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}).`;
}
}

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.

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.

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.