Validation

Learn about Zod integration, async validation, and error handling patterns.

Zod Integration

Basic Schema Validation (onDynamic)

The simplest way to add validation is to define a Zod schema and pass it to validators.onDynamic with revalidateLogic to control timing:

import { hasErrors } from "@tilt-legal/cubitt-components/form";
import {
  z } from "zod";

const schema = z.object({
  email: z.string().email("Please enter a valid email"),
  age: z.number().min(18,
  "You must be at least 18 years old"),
  password: z.string().min(8,
  "Password must be at least 8 characters"),
  });

const form = useForm({
  validationLogic: revalidateLogic({
    mode: "submit",
  modeAfterSubmission: "change",
  }),
  validators: {
    onDynamic: schema,
  },
  // ...
});

Cross-Field Validation

Use Zod's refine method for validation that depends on multiple fields:

const schema = z
  .object({
    password: z.string().min(8),
  confirmPassword: z.string(),
  })
  .refine((data) => data.password === data.confirmPassword,
  {
    message: "Passwords don't match",
  path: ["confirmPassword"],
  // Error appears on confirmPassword field
  });

Complex Validation Rules

Zod supports sophisticated validation patterns:

const userSchema = z.object({
  username: z
    .string()
    .min(3,
  "Username must be at least 3 characters")
    .max(20,
  "Username must be less than 20 characters")
    .regex(/^[a-zA-Z0-9_]+$/,
  "Only letters,
  numbers,
  and underscores allowed"),
  email: z
    .string()
    .email("Invalid email address")
    .refine((email) => !email.endsWith("@temp.com"),
  {
      message: "Temporary email addresses are not allowed",
  }),
  age: z.coerce
    .number()
    .int("Age must be a whole number")
    .min(13,
  "You must be at least 13 years old")
    .max(120,
  "Please enter a valid age"),
  website: z
    .string()
    .url("Please enter a valid URL")
    .optional()
    .or(z.literal("")),
  // Allow empty string
});

Custom Validation

Client-Side Custom Validation

For validation logic that can't be expressed with Zod, use custom validators:

const form = useForm({
  validators: {
    onChange: ({ value }) => {
      const errors: Record<string,
  string> = {};

      // Custom validation logic
      if (value.username && value.username.includes("admin")) {
        errors.username = "Username cannot contain 'admin'";
      }

      if (value.password !== value.confirmPassword) {
        errors.confirmPassword = "Passwords must match";
      }

      // Return errors object or undefined if no errors
      return Object.keys(errors).length > 0 ? { fields: errors } : undefined;
    },
  },
  });

Combining Zod and Custom Validation

You can use both Zod and custom validation together with onDynamic channels:

const form = useForm({
  validationLogic: revalidateLogic({
    mode: "submit",
  modeAfterSubmission: "change",
  }),
  validators: {
    onDynamic: zodSchema,
  onDynamicAsyncDebounceMs: 500,
  onDynamicAsync: async ({ value }) => customAsyncValidation(value),
  },
  });

Async Validation

Form with async validation for checking username availability or other server-side checks.

Async Validation Patterns

Debounced Validation

Prevent excessive API calls by debouncing async validation:

const form = useForm({
  validators: {
    onDynamicAsyncDebounceMs: 500,
  onDynamicAsync: async ({ value }) => {
      if (value.username) {
        const isAvailable = await checkUsername(value.username);
        if (!isAvailable) return { fields: { username: "Username taken" } };
      }
    },
  },
  });

Conditional Async Validation

Only run async validation when certain conditions are met:

validators: {
  onDynamicAsync: async ({ value }) => {
    // Only check username if it meets basic requirements
    if (value.username && value.username.length >= 3) {
      const result = await validateUsername(value.username);
      if (!result.isValid) {
        return { fields: { username: result.message } };
      }
    }
  },
  }

Multiple Async Checks

Validate multiple fields asynchronously:

validators: {
  onDynamicAsync: async ({ value }) => {
    const errors: Record<string,
  string> = {};

    // Check username availability
    if (value.username && value.username.length >= 3) {
      const usernameAvailable = await checkUsername(value.username);
      if (!usernameAvailable) {
        errors.username = "Username is taken";
      }
    }

    // Check email availability
    if (value.email && value.email.includes('@')) {
      const emailAvailable = await checkEmail(value.email);
      if (!emailAvailable) {
        errors.email = "Email is already registered";
      }
    }

    return Object.keys(errors).length > 0 ? { fields: errors } : undefined;
  },
  }

Error Handling

Form Submission Errors

Handle errors that occur during form submission:

const form = useForm({
  onSubmit: async ({ value,
  formApi }) => {
    try {
      await submitUserData(value);
      alert("Success! Account created.");
    } catch (error) {
      if (error instanceof ValidationError) {
        // Server returned validation errors - use updater function
        formApi.setFieldMeta("username",
  (prev) => ({
          ...prev,
  errorMap: { onSubmit: error.message },
  }));
      } else {
        // Generic error handling
        console.error("Submission failed:",
  error);
        alert("Something went wrong. Please try again.");
      }
    }
  },
  });

Field-Level Error Display

Access and display field-specific errors:

// Using the clean API - errors are handled automatically
<InputField name="username" label="Username" />

// Or access errors manually in advanced scenarios
<form.Field name="username">
  {(field) => {
    const hasError = field.state.meta.errors.length > 0;
    return (
      <div>
        <InputField field={field} label="Username" />
        {hasError && (
          <p className="text-red-500 text-sm mt-1">
            {field.state.meta.errors[0]}
          </p>
        )}
      </div>
    );
  }}
</form.Field>

Server-Side Validation

Handle validation errors returned from the server:

const form = useForm({
  onSubmit: async ({ value,
  formApi }) => {
    try {
      await createAccount(value);
    } catch (error) {
      if (error.status === 422) {
        // Server returned field-specific validation errors
        const serverErrors = error.response.data.errors;

        // Map server errors to form fields using the updater pattern
        for (const [fieldName,
  message] of Object.entries(serverErrors)) {
          formApi.setFieldMeta(fieldName,
  (prev) => ({
            ...prev,
  errorMap: { onSubmit: message },
  }));
        }
      }
    }
  },
  });

Validation Timing

onChange vs onSubmit

Choose when validation runs based on your UX requirements:

const form = useForm({
  validators: {
    // Real-time validation as user types
    onChange: schema,
  // Only validate when user submits
    onSubmit: schema,
  // Async validation (always debounced)
    onChangeAsync: asyncValidator,
  },
  });

Field-Level Validation Timing

Control validation timing for individual fields:

<form.Field
  name="password"
  validators={{
    onChange: ({ value }) =>
      value.length < 8 ? "Password too short" : undefined,
  onBlur: ({ value }) => validatePasswordStrength(value),
  }}
>
  {(field) => <InputField field={field} />}
</form.Field>

Helper Utilities

Cubitt provides type-safe helper functions for common form operations:

Error Extraction

import { extractErrorMessages } from "@tilt-legal/cubitt-components/form";

// Extract human-readable error messages from field meta
const errors = extractErrorMessages(field.state.meta.errors);
// ["Name is required", "Must be at least 3 characters"]

// Check if field has any validation errors
if (hasErrors(field.state.meta.errors)) {
  // Show error styling
}

Form Helpers

For advanced scenarios where you need to programmatically manipulate form state:

import {
  getFieldValue,
  setFieldErrors,
  setFieldMeta,
  setFieldValue,
  validateField,
  validateForm,
} from "@tilt-legal/cubitt-components/form";

// Set custom errors on a field
setFieldErrors(form, "email", ["Email is already registered"]);

// Programmatically set a field value
setFieldValue(form, "status", "approved");

// Trigger form-wide validation
validateForm(form);

// Validate a specific field
validateField(form, "email", "blur");

These utilities are primarily used internally by the FormBuilder components. For most use cases, the standard TanStack Form API is sufficient.

On this page