Skip to main content

3 min read

Form Validation Example

Validate forms declaratively with constraints.


The Module

import { createModule, createSystem, t } from '@directive-run/core';

const formModule = createModule("signup-form", {
  // Every field and its validation state lives in typed facts
  schema: {
    facts: {
      email: t.string(),
      password: t.string(),
      confirmPassword: t.string(),
      emailValid: t.boolean().nullable(),
      emailChecking: t.boolean(),
      submitted: t.boolean(),
    },
  },

  // All fields start empty, no validation has run yet
  init: (facts) => {
    facts.email = "";
    facts.password = "";
    facts.confirmPassword = "";
    facts.emailValid = null;
    facts.emailChecking = false;
    facts.submitted = false;
  },

  derive: {
    // Collect all password rule violations into a single array
    passwordErrors: (facts) => {
      const errors: string[] = [];
      if (facts.password.length < 8) {
        errors.push("Password must be at least 8 characters");
      }
      if (!/[A-Z]/.test(facts.password)) {
        errors.push("Password must contain uppercase letter");
      }
      if (!/[0-9]/.test(facts.password)) {
        errors.push("Password must contain a number");
      }
      return errors;
    },

    // Simple equality check between the two password fields
    passwordsMatch: (facts) =>
      facts.password === facts.confirmPassword,

    // Composed derivation – depends on other derivations and facts
    isValid: (facts, derive) =>
      facts.emailValid === true &&
      derive.passwordErrors.length === 0 &&
      derive.passwordsMatch,
  },

  constraints: {
    // Auto-check email availability once the user types a valid-looking address
    checkEmail: {
      when: (facts) =>
        facts.email.length > 0 &&
        facts.email.includes("@") &&
        facts.emailValid === null &&
        !facts.emailChecking,
      require: { type: "CHECK_EMAIL" },
    },

    // Only allow submission when every validation rule passes
    canSubmit: {
      when: (facts) =>
        facts.submitted &&
        facts.emailValid === true &&
        facts.password.length >= 8 &&
        /[A-Z]/.test(facts.password) &&
        /[0-9]/.test(facts.password) &&
        facts.password === facts.confirmPassword,
      require: { type: "SUBMIT_FORM" },
    },
  },

  resolvers: {
    // Hit the server to see if the email is already taken
    checkEmail: {
      requirement: "CHECK_EMAIL",
      resolve: async (req, context) => {
        context.facts.emailChecking = true;
        try {
          const available = await api.checkEmail(context.facts.email);
          context.facts.emailValid = available;
        } finally {
          context.facts.emailChecking = false;
        }
      },
    },

    // Submit the form payload and reset the submitted flag
    submitForm: {
      requirement: "SUBMIT_FORM",
      resolve: async (req, context) => {
        await api.signup({
          email: context.facts.email,
          password: context.facts.password,
        });
        context.facts.submitted = false;
      },
    },
  },
});

React Component

import { createSystem } from '@directive-run/core';
import { useFact, useDerived } from '@directive-run/react';

// Boot the form system once at module scope
const system = createSystem({ module: formModule });
system.start();

function SignupForm() {
  // Subscribe to raw field values (facts)
  const email = useFact(system, 'email');
  const password = useFact(system, 'password');
  const confirmPassword = useFact(system, 'confirmPassword');
  const emailValid = useFact(system, 'emailValid');
  const emailChecking = useFact(system, 'emailChecking');

  // Subscribe to computed validation state (derivations)
  const passwordErrors = useDerived(system, 'passwordErrors');
  const passwordsMatch = useDerived(system, 'passwordsMatch');
  const isValid = useDerived(system, 'isValid');

  return (
    // Setting submitted = true triggers the canSubmit constraint
    <form onSubmit={(e) => { e.preventDefault(); system.facts.submitted = true; }}>

      {/* Email field – resets validation on every keystroke */}
      <div>
        <input
          type="email"
          value={email}
          onChange={(e) => {
            system.facts.email = e.target.value;
            system.facts.emailValid = null;
          }}
        />
        {emailChecking && <span>Checking...</span>}
        {emailValid === false && <span>Email taken</span>}
        {emailValid === true && <span>Available</span>}
      </div>

      {/* Password field – errors auto-update via the passwordErrors derivation */}
      <div>
        <input
          type="password"
          value={password}
          onChange={(e) => { system.facts.password = e.target.value }}
        />
        {passwordErrors.map((err) => <p key={err}>{err}</p>)}
      </div>

      {/* Confirm password – passwordsMatch derivation drives the inline error */}
      <div>
        <input
          type="password"
          value={confirmPassword}
          onChange={(e) => { system.facts.confirmPassword = e.target.value }}
        />
        {!passwordsMatch && <span>Passwords must match</span>}
      </div>

      {/* Button stays disabled until every validation derivation passes */}
      <button disabled={!isValid}>Sign Up</button>
    </form>
  );
}

Next Steps

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

Directive - Constraint-Driven State Management for TypeScript