Multi-step Form
A domain owns which step is currently active. Each forward edge passes the submitted form data through a validation pipeline via a guard — blocking the transition when data is missing or malformed and keeping the domain on the current step.
Validation pipelines
Section titled “Validation pipelines”Each step gets its own pipeline that inspects the submitted form data and sets an error field in context if anything is invalid.
import { definePipeline, node, action, fork, target, terminal } from '@tde.io/plexis';
type CheckoutCtx = { shipping?: { address: string; city: string; zip: string }; payment?: { card: string; expiry: string }; error?: string;};
const validateShipping = definePipeline<CheckoutCtx>('validate-shipping', () => { node('check', () => { action(async (ctx) => { const s = ctx.shipping; if (!s?.address || !s?.city || !s?.zip) { return { error: 'Shipping address required' }; } return { error: undefined }; }); fork('fail', target('fail'), (ctx) => !!ctx.error); fork('pass', target('pass')); }); node('fail', terminal()); node('pass', terminal()); return { initial: 'check' };});
const validatePayment = definePipeline<CheckoutCtx>('validate-payment', () => { node('check', () => { action(async (ctx) => { const p = ctx.payment; if (!p?.card || !p?.expiry) { return { error: 'Payment details required' }; } return { error: undefined }; }); fork('fail', target('fail'), (ctx) => !!ctx.error); fork('pass', target('pass')); }); node('fail', terminal()); node('pass', terminal()); return { initial: 'check' };});The checkout domain
Section titled “The checkout domain”States map directly to form steps. The guard on each forward edge merges the submitted payload into a draft context and runs the step’s validation pipeline. When the pipeline reports an error the guard returns false, the transition is blocked, and the domain stays on the current step.
import { defineDomain, when, on, terminal } from '@tde.io/plexis';
const checkout = defineDomain<CheckoutCtx>('checkout', () => { when('shipping', () => {});
when('payment', () => { on('back', target('shipping')); });
when('review', () => { on('confirm', target('confirmed')); on('back', target('payment')); });
when('confirmed', terminal());
return { context: {}, initial: 'shipping' };});Running the form end to end
Section titled “Running the form end to end”// Try to advance without submitting data — guard rejectsconst blocked = await checkout.follow('next');console.log(blocked.status); // 'blocked' — domain stays on 'shipping'console.log(checkout.phase); // 'shipping'
// Submit valid shipping dataconst step1 = await checkout.follow('next', { address: '1 Main St', city: 'Springfield', zip: '12345',});console.log(step1.status); // 'followed'console.log(checkout.phase); // 'payment'
// Try to advance without payment dataconst blocked2 = await checkout.follow('next');console.log(blocked2.status); // 'blocked' — domain stays on 'payment'
// Submit valid payment dataconst step2 = await checkout.follow('next', { card: '4111111111111111', expiry: '12/28',});console.log(step2.status); // 'followed'console.log(checkout.phase); // 'review'
// Confirm the orderconst step3 = await checkout.follow('confirm');console.log(step3.status); // 'followed'console.log(checkout.phase); // 'confirmed'status: 'blocked' confirms the domain rejected the transition — no side effects ran, context did not change, and the form stays on the current step. The UI can read result.status to decide whether to show a validation error message.