R-A

Change your answer. Keep the right data.

Onboarding with a conditional branch and backtracking. Pick a payer type and the route changes; walk back across the branch and the answers are still there; finish, and the flow submits what it collected and nothing else.

This page ships the React binding and only that one. React is what the server renders when no framework is named, so the choice is a link rather than a switch: nothing a browser remembered can disagree with what arrived.

Your details

Who is paying
route
details → verify → payment → review
completed
none
edited
none

Starting.

The source it is running

These blocks are the files the application above imports, read at build time. There is no copy of them to fall out of date.

flow.ts

The route. Everything that decides where the visitor goes next is in this object, and nothing that decides it is anywhere else.

import { defineFlow, step } from '@wizzard-packages/core';
import { eq, get } from '@wizzard-packages/core/expr';

/**
 * R-A: onboarding with a conditional branch and backtracking.
 *
 * The whole route lives in this object. Nothing below it decides where the
 * visitor goes next, which is the point: the rendering is a switch over
 * `current`, and it stays a switch when a fourth step is added here.
 */
export const onboarding = defineFlow({
  id: 'onboarding',
  version: 1,
  order: ['details', 'verify', 'company', 'payment', 'review'],
  steps: {
    details: step<{ email: string; payer: 'personal' | 'business' }>({
      label: 'Details',
      validate: { $ref: 'details' },
    }),

    verify: step<{ code: string }>({
      label: 'Verify',
      validate: { $ref: 'verify' },
      // The code is a credential, not an answer. `true` drops the whole slice
      // whichever way the step is left, so it is never in the object the last
      // step submits and never sits in storage waiting to be restored.
      clearOnLeave: true,
      // A visitor who already has an account skips ahead. `company` and
      // `payment` stay reachable — they are jumped over, not excluded — so the
      // jump has to be an explicit target rather than a `when`.
      //
      // The list is exhaustive on purpose: a target list where nothing matches
      // ends the flow, so the ordinary route is spelled out after the fast
      // path. `company` is tried first and skipped when its own `when` is
      // false, which lands a personal payer on `payment`.
      on: { next: [{ to: 'review', when: get('ctx.returning') }, 'company', 'payment'] },
    }),

    company: step<{ name: string; vat: string }>({
      label: 'Company',
      // Reachability, not navigation: a step whose `when` is false is off the
      // route, so it is skipped forwards and backwards and drawn as a step this
      // visitor will not see.
      when: eq(get('data.details.payer'), 'business'),
      validate: { $ref: 'company' },
    }),

    payment: step<{ card: string }>({
      label: 'Payment',
      validate: { $ref: 'payment' },
    }),

    review: step({ label: 'Review' }),
  },
  policy: 'free',
});

registry.ts

What a flow cannot serialize: the validators, named by the `$ref` above and looked up at runtime.

import type { AsyncRegistry, Scope } from '@wizzard-packages/core';

/**
 * What the flow could not serialize. Each `$ref` in `flow.ts` is a key here,
 * and a validator answers with the field errors it found or `null`.
 *
 * They are ordinary functions of the data: no component, no engine, nothing to
 * mock. That is what makes the same four testable without a browser.
 */
type Fields = Record<string, string> | null;

const slice = (scope: Scope, id: string): Record<string, unknown> =>
  (scope.data[id] as Record<string, unknown> | undefined) ?? {};

const EMAIL = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;

export const registry: AsyncRegistry = {
  details: (_args, scope): Fields => {
    const { email, payer } = slice(scope, 'details');
    const errors: Record<string, string> = {};
    if (typeof email !== 'string' || email === '') errors['email'] = 'Enter your email address.';
    else if (!EMAIL.test(email)) errors['email'] = 'That does not look like an email address.';
    if (payer !== 'personal' && payer !== 'business') errors['payer'] = 'Choose who is paying.';
    return Object.keys(errors).length === 0 ? null : errors;
  },

  verify: (_args, scope): Fields => {
    const { code } = slice(scope, 'verify');
    // Six digits, and in a real application a call to the service that sent
    // them. The example keeps it local so the page works offline.
    if (typeof code !== 'string' || !/^\d{6}$/.test(code)) {
      return { code: 'The code is six digits.' };
    }
    return null;
  },

  company: (_args, scope): Fields => {
    const { name, vat } = slice(scope, 'company');
    const errors: Record<string, string> = {};
    if (typeof name !== 'string' || name.trim() === '') errors['name'] = 'Enter the company name.';
    if (typeof vat !== 'string' || vat.trim() === '') errors['vat'] = 'Enter the VAT number.';
    return Object.keys(errors).length === 0 ? null : errors;
  },

  payment: (_args, scope): Fields => {
    const { card } = slice(scope, 'payment');
    const digits = typeof card === 'string' ? card.replace(/\s/g, '') : '';
    if (!/^\d{16}$/.test(digits)) return { card: 'A card number is sixteen digits.' };
    return null;
  },
};

App.tsx

The rendering. It knows the fields and the labels; it asks the engine which step is current and draws that one.

import {
  WizardProvider,
  useErrors,
  useField,
  useNavigation,
  useStep,
  useWizard,
  useWizardSelector,
} from '@wizzard-packages/react';
import { useEffect, useRef, useState, type ReactNode } from 'react';

import { onboarding } from './flow';
import { registry } from './registry';

/**
 * R-A on the React binding.
 *
 * The rendering knows the fields and nothing else. It never asks "is this a
 * business payer" to decide what comes next - it asks the engine which step is
 * current and draws that one. Adding a branch is an edit to `flow.ts`.
 *
 * Accessibility is by hand here, deliberately: the bindings supply no ARIA in
 * 1.0, so this file is what that contract will be written from. Visible labels,
 * one polite live region for validation and step changes, and focus moved to
 * the heading of the step the visitor just arrived at.
 */
export default function OnboardingApp(): ReactNode {
  return (
    <WizardProvider flow={onboarding} registry={registry} ctx={{ returning: false }}>
      <Onboarding />
    </WizardProvider>
  );
}

const LABELS: Record<string, string> = {
  details: 'Your details',
  verify: 'Verify your email',
  company: 'Company details',
  payment: 'Payment',
  review: 'Review',
};

function Onboarding(): ReactNode {
  const wizard = useWizard();
  const { current, active, isLast } = useStep();
  const { back, canBack, isBusy } = useNavigation();
  const errors = useErrors();

  const [email, setEmail] = useField<string>('details.email');
  const [payer, setPayer] = useField<string>('details.payer');
  const [code, setCode] = useField<string>('verify.code');
  const [company, setCompany] = useField<string>('company.name');
  const [vat, setVat] = useField<string>('company.vat');
  const [card, setCard] = useField<string>('payment.card');

  const returning = useWizardSelector((s) => s.ctx['returning'] === true);
  const completed = useWizardSelector((s) => s.completed);
  const dirty = useWizardSelector((s) => s.dirty);
  const data = useWizardSelector((s) => s.data);

  const [announcement, setAnnouncement] = useState('');
  const [ended, setEnded] = useState(false);
  const heading = useRef<HTMLHeadingElement>(null);

  // Before `start` has run - on the server, and for the first paint - there is
  // no current step, so the form draws the step the flow is about to enter and
  // says so, instead of a spinner over an empty box.
  const standing = current ?? active[0] ?? 'details';
  const starting = current === null;

  // Focus follows the flow, but only once the visitor has moved: taking focus on
  // the first paint would drag a reader who was still reading the page above.
  //
  // The step id is the only dependency, and the count is read off the engine
  // inside: `active` is rebuilt on every commit, so depending on it would make
  // a keystroke look like a step change and pull focus out of the field being
  // typed into.
  const moved = useRef(false);
  useEffect(() => {
    if (current === null) return;
    if (!moved.current) {
      moved.current = true;
      return;
    }
    heading.current?.focus();
    const { active: route } = wizard.getSnapshot();
    const at = route.indexOf(current) + 1;
    setAnnouncement(`${LABELS[current] ?? current}. Step ${at} of ${route.length}.`);
  }, [current, wizard]);

  async function onNext(): Promise<void> {
    if (ended) {
      setEnded(false);
      setAnnouncement('');
      // `reset` keeps `ctx` - it is the host's, not the run's - so the fast
      // path has to be put back by hand, or starting again silently takes it.
      wizard.setCtx({ returning: false });
      wizard.reset();
      await wizard.start();
      return;
    }
    const result = await wizard.next();
    if (result.ok) {
      if (result.to === '@end') {
        setEnded(true);
        setAnnouncement('Onboarding complete.');
      }
      return;
    }
    // A refusal is the one thing the live region must carry: the fields below
    // show it too, but a screen reader is not looking at them.
    const fields = Object.values(result.errors ?? {});
    setAnnouncement(
      fields.length === 0 ? `That move was refused: ${result.reason}.` : fields.join(' ')
    );
  }

  if (ended) {
    return (
      <div className="app">
        <h2 ref={heading} tabIndex={-1}>
          Done
        </h2>
        <p>The submission is what the flow collected, and nothing else.</p>
        <pre className="app-data">{JSON.stringify(submission(data, active), null, 2)}</pre>
        <div className="actions">
          <button
            className="button button-accent"
            type="button"
            onClick={() => {
              void onNext();
            }}
          >
            Start again
          </button>
        </div>
        <Live text={announcement} />
      </div>
    );
  }

  return (
    <form
      className="app"
      onSubmit={(event) => {
        event.preventDefault();
        void onNext();
      }}
    >
      <h2 ref={heading} tabIndex={-1}>
        {LABELS[standing] ?? standing}
      </h2>

      {standing === 'details' && (
        <>
          <Field
            id="email"
            label="Email"
            type="email"
            autoComplete="email"
            value={email}
            onChange={setEmail}
            error={errors['email']}
          />
          <fieldset className="field">
            <legend className="field-label">Who is paying</legend>
            <div className="segmented">
              {(['personal', 'business'] as const).map((choice) => (
                <button
                  key={choice}
                  type="button"
                  aria-pressed={payer === choice}
                  onClick={() => {
                    setPayer(choice);
                  }}
                >
                  {choice === 'personal' ? 'Personal' : 'Business'}
                </button>
              ))}
            </div>
            {errors['payer'] !== undefined && <p className="field-error">{errors['payer']}</p>}
          </fieldset>
        </>
      )}

      {standing === 'verify' && (
        <>
          <Field
            id="code"
            label="Six-digit code"
            inputMode="numeric"
            autoComplete="one-time-code"
            hint="Any six digits will do here."
            value={code}
            onChange={setCode}
            error={errors['code']}
          />
          <label className="check">
            <input
              type="checkbox"
              checked={returning}
              onChange={(e) => {
                wizard.setCtx({ returning: e.target.checked });
              }}
            />
            I already have an account
          </label>
        </>
      )}

      {standing === 'company' && (
        <>
          <Field
            id="company-name"
            label="Company name"
            value={company}
            onChange={setCompany}
            error={errors['name']}
          />
          <Field id="vat" label="VAT number" value={vat} onChange={setVat} error={errors['vat']} />
        </>
      )}

      {standing === 'payment' && (
        <Field
          id="card"
          label="Card number"
          inputMode="numeric"
          autoComplete="cc-number"
          hint="Sixteen digits, spaces allowed."
          value={card}
          onChange={setCard}
          error={errors['card']}
        />
      )}

      {standing === 'review' && (
        <>
          <p>This is what the flow will submit.</p>
          <pre className="app-data">{JSON.stringify(submission(data, active), null, 2)}</pre>
        </>
      )}

      <div className="actions">
        <button className="button button-accent" type="submit" disabled={starting || isBusy}>
          {isLast ? 'Submit' : 'Next'}
        </button>
        <button
          className="button button-secondary"
          type="button"
          disabled={starting || !canBack || isBusy}
          onClick={() => {
            void back();
          }}
        >
          Back
        </button>
      </div>

      <dl className="app-state">
        <dt>route</dt>
        <dd>{active.join(' → ')}</dd>
        <dt>completed</dt>
        <dd>{completed.length === 0 ? 'none' : completed.join(', ')}</dd>
        <dt>edited</dt>
        <dd>{dirty.length === 0 ? 'none' : dirty.join(', ')}</dd>
      </dl>

      <Live text={starting ? 'Starting.' : announcement} />
    </form>
  );
}

/**
 * What the flow submits: the slices of the steps that are on the route.
 *
 * A branch the visitor walked away from keeps its answers - the engine clears
 * nothing when a `when` goes false, so going back to it finds them still there -
 * and it is the application that decides those answers are not part of this
 * submission. `verify` is missing for the other reason: `clearOnLeave` dropped
 * it the moment the step was left.
 */
function submission(
  data: Readonly<Record<string, unknown>>,
  active: readonly string[]
): Record<string, unknown> {
  const out: Record<string, unknown> = {};
  for (const id of active) {
    if (data[id] !== undefined) out[id] = data[id];
  }
  return out;
}

/**
 * A labelled text field.
 *
 * The label is a `for`/`id` pair rather than a wrapper, and the hint and the
 * error are named by `aria-describedby`: that keeps the accessible name of the
 * input to the label alone, while a screen reader still reads the refusal with
 * the field it belongs to. Wrapping all four in one `<label>` would fold the
 * error message into the name, and the field would announce itself differently
 * before and after a refusal.
 */
function Field(props: {
  id: string;
  label: string;
  value: string | undefined;
  onChange: (value: string) => void;
  type?: string;
  inputMode?: 'numeric';
  autoComplete?: string;
  hint?: string;
  error?: string | undefined;
}): ReactNode {
  const described = [
    props.hint === undefined ? null : `${props.id}-hint`,
    props.error === undefined ? null : `${props.id}-error`,
  ].filter((id): id is string => id !== null);

  return (
    <div className="field">
      <label className="field-label" htmlFor={props.id}>
        {props.label}
      </label>
      <input
        id={props.id}
        type={props.type ?? 'text'}
        inputMode={props.inputMode}
        autoComplete={props.autoComplete}
        value={props.value ?? ''}
        aria-invalid={props.error === undefined ? undefined : true}
        aria-describedby={described.length === 0 ? undefined : described.join(' ')}
        onChange={(e) => {
          props.onChange(e.target.value);
        }}
      />
      {props.hint !== undefined && (
        <span className="field-hint" id={`${props.id}-hint`}>
          {props.hint}
        </span>
      )}
      {props.error !== undefined && (
        <span className="field-error" id={`${props.id}-error`}>
          {props.error}
        </span>
      )}
    </div>
  );
}

/** One polite region for the whole application, so two announcements queue. */
function Live(props: { text: string }): ReactNode {
  return (
    <p className="app-live" role="status" aria-live="polite">
      {props.text}
    </p>
  );
}