Headless multi-step flows for React and Vue — the flow is JSON.

Order, conditions and validation live in a definition you can read, diff and ship. Below, one is written and then run on the engine: the form is a visitor filling it in, the console is what each call returned, and the graph is the definition. Press anything in the form and it is yours from there.

trip.flow.ts
import {
  defineFlow,
  getPath,
  group,
  step,
  type AsyncRegistry,
  type SubFlows,
} from '@wizzard-packages/core';
import { empty, eq, get, not, ref } from '@wizzard-packages/core/expr';

const passenger = defineFlow({
  id: 'passenger',
  order: ['details'],
  steps: { details: step({ label: 'Passenger', validate: ref('passport') }) },
});

export const trip = defineFlow({
  id: 'trip',
  version: 1,
  order: ['route', 'people', 'company', 'payment'],
  steps: {
    route: step({ label: 'Route' }),
    people: group({
      label: 'Passengers',
      flow: 'passenger',
      when: not(empty(get('data.passengers'))),
      repeat: { over: get('data.passengers'), keyBy: 'id' },
    }),
    company: step({ label: 'Company', when: eq(get('data.business'), true) }),
    payment: step({ label: 'Payment' }),
  },
});

export const subFlows: SubFlows = { passenger };

export const registry: AsyncRegistry = {
  passport: (_args, { data, loop }) =>
    getPath(data, `answers.${loop?.key}.passport`) ? null : { passport: 'required' },
};

Route

Calls made on the form, and what they return.

RoutePassengers!empty(data.passengers)!empty(data.passengers)Companydata.business == truedata.business == truePayment
Steps of trip
StepKindStateCondition
Routestepactivealways
Passengersgrouprest!empty(data.passengers)
Companystepskippeddata.business == true
Paymentsteprestalways
@endendrestalways
Get startedHow a flow is definedinstallpnpm add @wizzard-packages/core@canary @wizzard-packages/react@canary

Change your answer. Keep the right data.

A branch the visitor left behind stops applying, and the data it collected stops being submitted. The definition says which, so no component has to remember.

Run this one, on React or Vue

DetailsCompanydata.payer == "business"data.payer == "business"Payment
Steps of signup
StepKindStateCondition
Detailsstepvisitedalways
Companystepskippeddata.payer == "business"
Paymentstepvisitedalways
@endenddonealways
DetailsCompanydata.payer == "business"data.payer == "business"Payment
Steps of signup
StepKindStateCondition
Detailsstepvisitedalways
Companystepskippeddata.payer == "business"
Paymentstepvisitedalways
@endenddonealways
signup with payer = personal. Company is dashed: its `when` is false, so it is not on the route and the data it would collect is not submitted.

Reload halfway through. Continue safely.

A saved run restores to the step it left, or says plainly why it could not - an older definition version, or storage the browser refused.

Run this one, on React or Vue

PlanAdd-onsdata.plan == "pro"data.plan == "pro"Confirm
Steps of upgrade
StepKindStateCondition
Planstepactivealways
Add-onsstepskippeddata.plan == "pro"
Confirmsteprestalways
@endendrestalways
PlanAdd-onsdata.plan == "pro"data.plan == "pro"Confirm
Steps of upgrade
StepKindStateCondition
Planstepactivealways
Add-onsstepskippeddata.plan == "pro"
Confirmsteprestalways
@endendrestalways
upgrade with plan = basic. Add-ons is off the route, so a session restored here lands on Confirm rather than a step that no longer exists.

Add passengers. Revisit any passenger.

A repeat group runs a sub-flow per item and lets the visitor go back into one without unwinding the rest.

Run this one, on React or Vue

passengers!empty(data.passengers)!empty(data.passengers)Review
Steps of trip
StepKindStateCondition
passengersgroupactive!empty(data.passengers)
Reviewsteprestalways
@endendrestalways
passengers!empty(data.passengers)!empty(data.passengers)Review
Steps of trip
StepKindStateCondition
passengersgroupactive!empty(data.passengers)
Reviewsteprestalways
@endendrestalways
trip with two passengers. The group runs the passenger sub-flow once per item, and Review waits behind it.

The rest of the library

Small, and measured on every build

Gzipped, as size-limit measured it when this page was built. Core is what callingcreateWizard costs; every other bar is what one more import adds to that application, with the code it shares with core counted once. persist stores through snapshot, so its bar includes it.

  • core7.46 kB
  • groups2.32 kB
  • persist2.30 kB
  • snapshot1.17 kB
  • react1.01 kB
  • vue539 B
  • validate131 B

The same hooks in React and Vue

The two bindings export the same hook names over one engine. The two below, quoted from the quickstart, return the same fields in each; Vue hands out the state among them as refs.

react
const { current, isLast } = useStep();
const { next, back, canBack } = useNavigation();
vue
const { current, isLast } = useStep();
const { next, back, canBack } = useNavigation();
The hooks, one by one

Validation by the schema you already have

@wizzard-packages/validate takes any Standard Schema; its tests run Zod and Yup. A refused step names each field in the schema's own words.

tripRules: schema(z.object({ name: z.string().min(1), age: z.number().min(18) })),
errors: {
  name: 'Invalid input: expected string, received undefined',
  age: 'Too small: expected number to be >=18'
},
Validation

Runs without a framework

The engine is a plain object. A test or a script makes the same calls a component makes, with no DOM to render.

await wizard.start();
console.log(`step: ${String(wizard.getSnapshot().current)}`);

wizard.set('name.full', 'Ada');
await wizard.next();
console.log(`step: ${String(wizard.getSnapshot().current)}`);
console.log(`name: ${String(wizard.get('name.full'))}`);
step: name
step: review
name: Ada
Testing a flow

A broken flow, caught before it runs

A flow from a backend is JSON, and JSON carries no types. This one would run, and Company would never show. validateFlow says why when the application asks it; its answer below came from the engine when this page was built.

import { validateFlow } from '@wizzard-packages/core/validate-flow';

import type { FlowDefinition } from '@wizzard-packages/core';

export const flow: FlowDefinition = {
  id: 'signup',
  order: ['account', 'company'],
  steps: {
    account: {},
    company: { when: { $eq: [{ $get: 'account.type' }, 'company'] } },
  },
};

export const problems = validateFlow(flow);
[wizzard] $get "account.type" does not start with data, ctx, loop. The first segment names where a path reads from, and any other start evaluates to undefined. Start the path with the root it belongs to, such as data.account.type. https://zizzx.github.io/wizzard-packages/errors/get-unknown-root
Flows from a server
Supports
React >= 18
Vue >= 3.5
Node >= 20.11