@mackehansson/schemaform

Custom Validators

Add sync and async field checks to useFormRenderer or the ShadCN renderer.

JSON Schema covers structural validation: required fields, formats, lengths, numbers, enums, and similar constraints. Custom validators are for checks your app owns, such as username availability, invitation codes, checksums, or cross-system business rules.

Register validators on the React hook, keyed by JSON Pointer. Their results merge into the same error state as Ajv errors, so page gating, submit blocking, errorsFor(pointer), and error summaries all see one error system.

Sync validators

A validator receives the current field value and the full Working State. Return a string to report an error, or null / undefined when the value is valid.

import { useMemo, useState } from "react";
import type { FormDefinition, WorkingState } from "@mackehansson/schemaform";
import { useFormRenderer, type Validators } from "@mackehansson/schemaform";

const reservedUsernames = new Set(["admin", "root", "support"]);

export function UsernameForm({ definition }: { definition: FormDefinition }) {
  const [data, setData] = useState<WorkingState>({});

  const validators = useMemo<Validators>(
    () => ({
      "/username": (value) => {
        if (typeof value !== "string" || value.trim() === "") return null;
        return reservedUsernames.has(value.toLowerCase())
          ? "That username is reserved"
          : null;
      },
    }),
    [],
  );

  const form = useFormRenderer({ definition, data, validators });

  return (
    <form>
      <input
        value={String(data.username ?? "")}
        onChange={(event) =>
          setData((current) => ({ ...current, username: event.target.value }))
        }
        onBlur={() => form.onFieldBlur("/username")}
      />

      {form.errorsFor("/username").map((error) => (
        <p key={error.keyword}>{error.message}</p>
      ))}
    </form>
  );
}

Keep the validators object stable with useMemo or by defining it outside the component. The hook is controlled: your form library or component state owns values, and Schemaform resolves validation from the current Working State.

Async validators

Return a promise when the check needs the network or another async source. While a visible field validator is pending, isValid is false, hasPendingValidators is true, and the pointer appears in pendingPointers. Stale async results are ignored when the user changes the value before the older request resolves.

import { useMemo } from "react";
import { SchemaForm } from "@mackehansson/schemaform-shadcn";
import type { FormDefinition } from "@mackehansson/schemaform";
import type { Validators } from "@mackehansson/schemaform";

async function isUsernameAvailable(username: string): Promise<boolean> {
  const response = await fetch(
    `/api/usernames/${encodeURIComponent(username)}`,
  );
  const result = (await response.json()) as { available: boolean };
  return result.available;
}

export function SignupForm({ definition }: { definition: FormDefinition }) {
  const validators = useMemo<Validators>(
    () => ({
      "/username": async (value) => {
        if (typeof value !== "string" || value.length < 3) return null;

        const available = await isUsernameAvailable(value);
        return available ? null : "That username is already taken";
      },
    }),
    [],
  );

  return (
    <SchemaForm
      definition={definition}
      validators={validators}
      onSubmit={(payload) => console.log(payload)}
    />
  );
}

The Form Builder does not let admin users author validator code in v1. Designer-attachable named validators can layer on later, but today validators are developer-owned functions registered at render time.

Choosing the right layer

Use JSON Schema when a constraint can travel with the Form Definition: required, minLength, format, numeric bounds, and enum choices belong there.

Use a custom validator when the check depends on application services, secrets, live data, or code that should not be persisted in the Form Definition. The returned error still behaves like any other validation error for the Form Renderer.

On this page