Skip to content
@tde.io/plexis  ·  npm install @tde.io/plexis  ·  npm  ·  GitHub

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.

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' };
});

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' };
});
// Try to advance without submitting data — guard rejects
const blocked = await checkout.follow('next');
console.log(blocked.status); // 'blocked' — domain stays on 'shipping'
console.log(checkout.phase); // 'shipping'
// Submit valid shipping data
const 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 data
const blocked2 = await checkout.follow('next');
console.log(blocked2.status); // 'blocked' — domain stays on 'payment'
// Submit valid payment data
const step2 = await checkout.follow('next', {
card: '4111111111111111',
expiry: '12/28',
});
console.log(step2.status); // 'followed'
console.log(checkout.phase); // 'review'
// Confirm the order
const 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.