Skip to main content

Examples

Form Wizard

Multi-step form with constraint-gated advancement, async validation, and persistence for save-and-resume.

Try it

Loading example…

Fill in each step and click Next. The button is disabled until the step validates. Go back to see data preserved. Try “taken@test.com” for async email validation.

How it works

A wizard module manages step state and field data, while a validation module handles async checks – composed with constraint ordering and persistence.

  1. Facts currentStep, per-step field facts (email, password, name, plan), and advanceRequested
  2. Derivations – per-step validators (step0Valid, step1Valid, step2Valid), currentStepValid, canAdvance, and progress percentage
  3. Constraints advance (priority 50) only fires when both advanceRequested and currentStepValid are true
  4. Persistence persistencePlugin saves field values and current step, enabling save-and-resume across page reloads

Summary

What: A three-step form wizard with per-step validation, async email availability checks, and persistent draft state.

How: Derivations compute step validity. The advance constraint gates on currentStepValid, preventing advancement until all fields pass. The persistence plugin saves progress automatically.

Why it works: Constraint-gated advancement replaces imperative validation chains. Back navigation preserves all data because facts persist until explicitly cleared. The persistence plugin enables resume without any custom save logic.

Source code

/**
 * Form Wizard — Directive Modules
 *
 * Two-module system demonstrating multi-step form validation,
 * constraint-driven step advancement, cross-module async email
 * availability checking, and persistence of draft data.
 *
 * - wizard module: step navigation, field data, derivations for per-step
 *   validity, constraints to advance/submit, resolvers for step transitions.
 * - validation module: cross-module email availability check using
 *   crossModuleDeps on the wizard schema.
 */

import { createModule, createSystem, t, type ModuleSchema } from "@directive-run/core";
import {persistencePlugin, devtoolsPlugin } from "@directive-run/core/plugins";

// ============================================================================
// Types
// ============================================================================

export type PlanType = "free" | "pro" | "enterprise";

// ============================================================================
// Wizard Schema
// ============================================================================

export const wizardSchema = {
  facts: {
    currentStep: t.number(),
    totalSteps: t.number(),
    advanceRequested: t.boolean(),
    email: t.string(),
    password: t.string(),
    name: t.string(),
    company: t.string(),
    plan: t.string<PlanType>(),
    newsletter: t.boolean(),
    submitted: t.boolean(),
  },
  derivations: {
    step0Valid: t.boolean(),
    step1Valid: t.boolean(),
    step2Valid: t.boolean(),
    currentStepValid: t.boolean(),
    canAdvance: t.boolean(),
    canGoBack: t.boolean(),
    progress: t.number(),
    isLastStep: t.boolean(),
  },
  events: {
    requestAdvance: {},
    goBack: {},
    setField: { field: t.string(), value: t.object<unknown>() },
    reset: {},
  },
  requirements: {
    ADVANCE_STEP: {},
    SUBMIT_FORM: {},
  },
} satisfies ModuleSchema;

// ============================================================================
// Helpers
// ============================================================================

/** Inline step validity check for use in constraints (which only receive facts). */
function isStepValid(facts: Record<string, unknown>, step: number): boolean {
  if (step === 0) {
    return (facts.email as string).includes("@") && (facts.password as string).length >= 8;
  }
  if (step === 1) {
    return (facts.name as string).trim().length > 0;
  }
  if (step === 2) {
    return (facts.plan as string) !== "";
  }

  return false;
}

// ============================================================================
// Wizard Module
// ============================================================================

export const wizardModule = createModule("wizard", {
  schema: wizardSchema,

  init: (facts) => {
    facts.currentStep = 0;
    facts.totalSteps = 3;
    facts.advanceRequested = false;
    facts.email = "";
    facts.password = "";
    facts.name = "";
    facts.company = "";
    facts.plan = "free";
    facts.newsletter = false;
    facts.submitted = false;
  },

  // ============================================================================
  // Derivations
  // ============================================================================

  derive: {
    step0Valid: (facts) => {
      return facts.email.includes("@") && facts.password.length >= 8;
    },

    step1Valid: (facts) => {
      return facts.name.trim().length > 0;
    },

    step2Valid: (facts) => {
      return facts.plan !== "";
    },

    currentStepValid: (facts, derive) => {
      if (facts.currentStep === 0) {
        return derive.step0Valid;
      }
      if (facts.currentStep === 1) {
        return derive.step1Valid;
      }
      if (facts.currentStep === 2) {
        return derive.step2Valid;
      }

      return false;
    },

    canAdvance: (facts, derive) => {
      return derive.currentStepValid && facts.currentStep < facts.totalSteps - 1;
    },

    canGoBack: (facts) => {
      return facts.currentStep > 0;
    },

    progress: (facts) => {
      return Math.round(((facts.currentStep + 1) / facts.totalSteps) * 100);
    },

    isLastStep: (facts) => {
      return facts.currentStep === facts.totalSteps - 1;
    },
  },

  // ============================================================================
  // Events
  // ============================================================================

  events: {
    requestAdvance: (facts) => {
      facts.advanceRequested = true;
    },

    goBack: (facts) => {
      if (facts.currentStep > 0) {
        facts.currentStep = facts.currentStep - 1;
      }
    },

    setField: (facts, { field, value }) => {
      (facts as Record<string, unknown>)[field] = value;
    },

    reset: (facts) => {
      facts.currentStep = 0;
      facts.advanceRequested = false;
      facts.email = "";
      facts.password = "";
      facts.name = "";
      facts.company = "";
      facts.plan = "free";
      facts.newsletter = false;
      facts.submitted = false;
    },
  },

  // ============================================================================
  // Constraints
  // ============================================================================

  constraints: {
    submit: {
      priority: 60,
      when: (facts) => {
        const isLastStep = facts.currentStep === facts.totalSteps - 1;
        const stepValid = isStepValid(facts, facts.currentStep);

        return facts.advanceRequested && isLastStep && stepValid;
      },
      require: { type: "SUBMIT_FORM" },
    },

    advance: {
      priority: 50,
      when: (facts) => {
        const isLastStep = facts.currentStep === facts.totalSteps - 1;
        const stepValid = isStepValid(facts, facts.currentStep);

        return facts.advanceRequested && !isLastStep && stepValid;
      },
      require: { type: "ADVANCE_STEP" },
    },
  },

  // ============================================================================
  // Resolvers
  // ============================================================================

  resolvers: {
    advanceStep: {
      requirement: "ADVANCE_STEP",
      resolve: async (req, context) => {
        context.facts.currentStep = context.facts.currentStep + 1;
        context.facts.advanceRequested = false;
      },
    },

    submitForm: {
      requirement: "SUBMIT_FORM",
      timeout: 10000,
      resolve: async (req, context) => {
        // Simulate API submission
        await new Promise((resolve) => setTimeout(resolve, 800));
        context.facts.submitted = true;
        context.facts.advanceRequested = false;
      },
    },
  },
});

// ============================================================================
// Validation Schema
// ============================================================================

export const validationSchema = {
  facts: {
    emailAvailable: t.boolean(),
    checkingEmail: t.boolean(),
    emailChecked: t.string(),
  },
  derivations: {},
  events: {},
  requirements: {
    CHECK_EMAIL: { email: t.string() },
  },
} satisfies ModuleSchema;

// ============================================================================
// Validation Module
// ============================================================================

export const validationModule = createModule("validation", {
  schema: validationSchema,

  crossModuleDeps: { wizard: wizardSchema },

  init: (facts) => {
    facts.emailAvailable = true;
    facts.checkingEmail = false;
    facts.emailChecked = "";
  },

  // ============================================================================
  // Constraints
  // ============================================================================

  constraints: {
    checkEmail: {
      when: (facts) => {
        const email = facts.wizard.email;
        const checked = facts.self.emailChecked;

        return email.includes("@") && email !== checked;
      },
      require: (facts) => ({
        type: "CHECK_EMAIL",
        email: facts.wizard.email,
      }),
    },
  },

  // ============================================================================
  // Resolvers
  // ============================================================================

  resolvers: {
    checkEmail: {
      requirement: "CHECK_EMAIL",
      resolve: async (req, context) => {
        context.facts.checkingEmail = true;

        try {
          // Simulate API availability check
          await new Promise((resolve) => setTimeout(resolve, 500));
          context.facts.emailAvailable = req.email !== "taken@test.com";
          context.facts.emailChecked = req.email;
        } finally {
          context.facts.checkingEmail = false;
        }
      },
    },
  },
});

// ============================================================================
// System
// ============================================================================

export const system = createSystem({
  modules: {
    wizard: wizardModule,
    validation: validationModule,
  },
  plugins: [
    devtoolsPlugin({ name: "form-wizard" }),
    persistencePlugin({
      storage: localStorage,
      key: "form-wizard-draft",
      include: [
        "wizard::email",
        "wizard::name",
        "wizard::company",
        "wizard::plan",
        "wizard::currentStep",
      ],
    }),
  ],
});

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works