gform-react
Back to Guides

Schema Validation (Zod, Yup, Valibot…)

Drive the whole form from one schema with GValidator.withSchema / withSchemaAsync. First-class support for any library implementing Standard Schema, with zero runtime dependencies.

Still optional

gform-react validates everything on its own - native constraints, custom rules, and async checks cover every case without a schema library. Reach for withSchema only if you already have a schema (e.g. one shared with your backend) and want it to be the single source of truth.

First-class Standard Schema support

GValidator.withSchema(schema) / withSchemaAsync(schema) wire a single Standard Schema directly to the '*' validator. gform reads the schema's ['~standard'] contract itself - no adapter, no runtime dependency - so any library implementing it works out of the box: Zod ≥3.24, Valibot, ArkType (sync), Yup ≥1.7 (async), Joi ≥18.0.0.

Unlike routing each field to its own leaf sub-schema, gform parses the whole form object on every validation pass, so object-level rules - .refine() / .superRefine(), conditional-required, confirm-password - fire too, and each issue routes to the field named by its path:

import { z } from "zod";
import { GValidator, type GValidators } from "gform-react";
 
const signUpSchema = z
  .object({
    email: z.string().email("Enter a valid email"),
    password: z.string().min(8, "At least 8 characters"),
    confirm: z.string(),
  })
  .refine((data) => data.password === data.confirm, {
    message: "Passwords must match",
    path: ["confirm"], // routes the cross-field error onto the `confirm` field
  });
 
const validators: GValidators<SignUpForm> = {
  "*": new GValidator().withSchema(signUpSchema),
};

Use '*' so the whole form is validated as one object - per-field entries would only see that field's own value, which defeats cross-field rules.

Sync vs. async: withSchema vs. withSchemaAsync

  • withSchema - for schemas whose '~standard'.validate() returns synchronously (Zod, Valibot, ArkType). Runs inline, like a constraint check.
  • withSchemaAsync - for schemas that return a Promise (Yup, or sync schemas with async refinements). Runs on the same debounced path as withCustomValidationAsync (the debounce prop on GInput, default 300 ms).

Passing an async schema to withSchema doesn't block submission correctly - in development it logs a one-time warning telling you to switch to withSchemaAsync.

import { object, string, ref } from "yup";
import { GValidator, type GValidators } from "gform-react";
 
const signUpSchema = object({
  email: string().email("Enter a valid email").required("Required"),
  password: string().min(8, "At least 8 characters").required("Required"),
  confirm: string().oneOf([ref("password")], "Passwords must match").required("Required"),
});
 
const validators: GValidators<SignUpForm> = {
  "*": new GValidator().withSchemaAsync(signUpSchema),
};

Each pass is cached by a signature of the current form values, so re-parsing only happens when a value actually changes - not on every keystroke across unrelated fields.

Keeping the other side of a pair in sync: validatorDeps

A schema issue updates the field its path points to - but only when that field validates. In a confirm-password pair, fixing password should also re-check confirm even though the user didn't touch it. Give the dependent field validatorDeps, listing the fields it should re-validate against:

<GInput formKey="confirm" required validatorDeps={["password"]} />

With this, editing password re-runs confirm's validator too, so a stale "Passwords must match" clears as soon as the user fixes the other field.

Typing a shared schema

StandardSchemaV1 is exported for annotating a schema that's shared across your app (e.g. with a server action), without committing to one schema library's own type:

import type { StandardSchemaV1 } from "gform-react";
 
export const signUpSchema: StandardSchemaV1<SignUpForm> = z.object({ /* ... */ });

Try it live

Zod schema with a .refine() confirm-password rule, wired to '*' via withSchema. The confirm field declares validatorDeps={["password"]} so it re-checks as password changes:

Loading playground…

Delegating to a library without Standard Schema

If you use a validator that doesn't implement ['~standard'], you can still delegate per-field inside withCustomValidation - map the result to gform's true-means-error contract yourself:

const validators: GValidators<Form> = {
  email: new GValidator().withCustomValidation((input) => {
    const result = someValidator.check(input.value);
    if (!result.ok) {
      input.errorText = result.message;
      return true; // invalid
    }
    return false; // valid
  }),
};

This loses the whole-object parsing withSchema gives you - object-level rules only see the one field's value - so prefer withSchema/withSchemaAsync whenever the library implements Standard Schema.

Next

  • Custom Validation - the withCustomValidation contract in full, including manual cross-field rules.
  • Async Validation - the debounced async path withSchemaAsync runs on.
  • GValidator - execution order and the mutation gotcha.