rilaykit
Forms

Universal Validation with Standard Schema

Learn how to use validation with any Standard Schema compatible library.

RilayKit validates with any Standard Schema compatible library — Zod (3.24+), Yup (1.7+), Joi (18+), Valibot (1.0+), ArkType (2.0+) — no adapters needed.

Field Validation

Each field's validation object accepts:

PropertyDescription
validateA Standard Schema validator, or an array of them
debounceMsDebounce validation runs (async cost control); blur and submit always validate immediately

Built-ins and external schemas mix freely in the same array:

import { z } from 'zod';
import { rilay } from '@/lib/rilay';
import { form } from '@rilaykit/forms';
import { required } from '@rilaykit/core';

const registrationForm = form.create(rilay, 'registration')
  .add({
    id: 'email',
    type: 'email',
    props: { label: 'Email Address' },
    validation: {
      validate: [
        required('Email is required'),            // RilayKit built-in
        z.string().email('Invalid email format'), // Zod schema
      ],
    },
  })
  .add({
    id: 'password',
    type: 'password',
    props: { label: 'Password' },
    validation: {
      validate: z.string().min(8, 'Password too short'),
    },
  });

Validation Timing

Timing is configured form-wide via .setValidation({ mode, reValidateMode }), mirroring React Hook Form:

OptionValuesDefaultMeaning
mode'onSubmit' | 'onBlur' | 'onChange' | 'onTouched' | 'all''onTouched'When a field validates for the first time
reValidateMode'onChange' | 'onBlur' | 'onSubmit''onChange'When an errored field re-validates
const strictForm = form.create(rilay, 'strict')
  .add({ id: 'email', type: 'email', validation: { validate: email() } })
  .setValidation({ mode: 'onBlur', reValidateMode: 'onChange' });

The default 'onTouched' validates a field on its first blur, then live. Submit always validates everything and marks errored fields touched, so touched-gated renderers show submit errors — and they clear live as the user types.

Built-in Validators

All from @rilaykit/core, all Standard Schema compatible:

  • required(message?), email(message?), url(message?)
  • minLength(min, message?), maxLength(max, message?), pattern(regex, message?)
  • number(message?), min(value, message?), max(value, message?)
  • custom(fn, message?), async(fn, message?), combine(...validators)

Creating Custom Standard Schema Validators

import type { StandardSchemaV1 } from '@standard-schema/spec';

export function containsRilay(message = 'Value must contain "rilay"'): StandardSchemaV1<string> {
  return {
    '~standard': {
      version: 1,
      vendor: 'my-app',
      validate: (value: unknown) => {
        if (typeof value === 'string' && value.includes('rilay')) {
          return { value };
        }
        return { issues: [{ message }] };
      },
    },
  };
}

Use it like any other validator: validation: { validate: [required(), containsRilay()] }.

Form-Level Validation

For cross-field rules, pass an object schema (or a custom validator over the form data) to .setValidation:

import { z } from 'zod';

const userSchema = z.object({
  password: z.string().min(8, 'Password too short'),
  confirmPassword: z.string(),
}).refine(data => data.password === data.confirmPassword, {
  message: "Passwords don't match",
  path: ['confirmPassword'],
});

const changePasswordForm = form.create(rilay, 'change-password')
  .add({ type: 'password', id: 'password', props: { label: 'New Password' } })
  .add({ type: 'password', id: 'confirmPassword', props: { label: 'Confirm Password' } })
  .setValidation({ validate: userSchema });

Form-level issues are routed by issue.path:

  • A path naming a known field attaches the error to that field — useFieldErrors(id) receives it alongside the field's own errors.
  • An empty or unmatched path lands in a reserved __form__ bucket, read via useFormErrors():
import { useFormErrors } from 'rilaykit/react';

function FormErrorBanner() {
  const errors = useFormErrors();
  if (errors.length === 0) return null;
  return (
    <div role="alert">
      {errors.map(e => <p key={e.message}>{e.message}</p>)}
    </div>
  );
}

Form-level rules re-run on the same mode/reValidateMode cadence as field rules and on repeatable row changes, so cross-field errors appear and clear live — no resubmit needed. isValid counts the whole error map, __form__ included.

Repeatable fields use composite ids (items[k0].price) that never match a schema's dot path (items.0.price): cross-field issues over repeatable rows land in __form__.

Async Validation

Standard Schema supports async validators; pair them with debounceMs to limit API calls:

const emailField = {
  id: 'email',
  type: 'email',
  props: { label: 'Email' },
  validation: {
    validate: z.string()
      .email('Invalid email format')
      .refine(async (email) => {
        const response = await fetch(`/api/check-email?email=${email}`);
        const { isUnique } = await response.json();
        return isUnique;
      }, 'Email is already taken'),
    debounceMs: 500,
  },
};

On this page