React Hook Form + Zod

Previous

Real form examples with schema validation, inline animated errors, and asynchronous submit state.

Request access

Zod errors are passed directly to each form component.

No card required.
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";

import { Button } from "@/components/matos-ui/button";
import { FormGrid } from "@/components/matos-ui/form-grid";
import { FormSection } from "@/components/matos-ui/form-section";
import { InputField } from "@/components/matos-ui/input";
import { Select } from "@/components/matos-ui/select";

const workspaceSchema = z.object({
  email: z.email("Enter a valid work email."),
  teamSize: z.string().min(1, "Choose a team size."),
});

type WorkspaceValues = z.infer<typeof workspaceSchema>;

export function FormWithZodDemo() {
  const {
    control,
    register,
    handleSubmit,
    formState: { errors, isSubmitting, isSubmitSuccessful },
  } = useForm<WorkspaceValues>({
    resolver: zodResolver(workspaceSchema),
    defaultValues: { email: "", teamSize: "" },
    mode: "onBlur",
  });

  async function onSubmit() {
    await new Promise((resolve) => window.setTimeout(resolve, 800));
  }

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="mx-auto w-full max-w-xl"
      noValidate
    >
      <FormSection
        size="compact"
        title="Request access"
        description="Zod errors are passed directly to each form component."
        footer={
          <>
            <span className="text-xs text-muted-foreground">
              {isSubmitSuccessful
                ? "Request ready for review."
                : "No card required."}
            </span>
            <Button
              type="submit"
              size="sm"
              loading={isSubmitting}
              loadingText="Sending..."
            >
              Continue
            </Button>
          </>
        }
      >
        <FormGrid gap="compact">
          <InputField
            label="Work email"
            placeholder="you@company.com"
            required
            error={errors.email?.message}
            {...register("email")}
          />
          <Controller
            name="teamSize"
            control={control}
            render={({ field }) => (
              <Select
                label="Team size"
                required
                options={[
                  { value: "small", label: "1-10 people" },
                  { value: "medium", label: "11-50 people" },
                  { value: "large", label: "51+ people" },
                ]}
                value={field.value || null}
                onValueChange={(nextValue) => field.onChange(nextValue ?? "")}
                name={field.name}
                inputRef={field.ref}
                error={errors.teamSize?.message}
                placeholder="Choose size"
              />
            )}
          />
        </FormGrid>
      </FormSection>
    </form>
  );
}

Dependencies

pnpm install react-hook-form zod @hookform/resolvers

Schema And Resolver

const schema = z.object({
  email: z.email("Enter a valid work email."),
  teamSize: z.string().min(1, "Choose a team size."),
})

const form = useForm({
  resolver: zodResolver(schema),
  defaultValues: { email: "", teamSize: "" },
})

Native-shaped controls accept register(...) directly. Use Controller for Select, PasswordInput, or Checkbox when their controlled value API is the clearest fit.

Full Signup Example

Create your account

A focused onboarding form with secure validation and compact inset surfaces.

Optional. Tell us what you want to build.

Required before creating an account.

Your information is securely reviewed before submission.

"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { Controller, useForm } from "react-hook-form";
import { z } from "zod";

import { Button } from "@/components/matos-ui/button";
import { CheckboxField } from "@/components/matos-ui/checkbox";
import { FormGrid, FormGridItem } from "@/components/matos-ui/form-grid";
import { FormSection } from "@/components/matos-ui/form-section";
import { InputField } from "@/components/matos-ui/input";
import { PasswordInput } from "@/components/matos-ui/password-input";
import { Select } from "@/components/matos-ui/select";
import { TextareaField } from "@/components/matos-ui/textarea";

const signupSchema = z
  .object({
    firstName: z.string().trim().min(2, "Enter your first name."),
    lastName: z.string().trim().min(2, "Enter your last name."),
    email: z.email("Enter a valid email address."),
    phone: z.string().trim().min(10, "Enter a valid phone number."),
    password: z
      .string()
      .min(8, "Use at least 8 characters.")
      .regex(/[A-Z]/, "Include one uppercase letter.")
      .regex(/[a-z]/, "Include one lowercase letter.")
      .regex(/\d/, "Include one number.")
      .regex(/[^A-Za-z0-9]/, "Include one special character."),
    confirmPassword: z.string(),
    role: z.string().min(1, "Select an area of interest."),
    note: z.string().max(220, "Use up to 220 characters.").optional(),
    terms: z.boolean().refine((value) => value, {
      message: "Accept the terms to continue.",
    }),
  })
  .refine((values) => values.password === values.confirmPassword, {
    message: "Passwords must match.",
    path: ["confirmPassword"],
  });

type SignupValues = z.infer<typeof signupSchema>;

const roleOptions = [
  {
    value: "design",
    label: "Design",
    description: "Systems, interaction and visual craft.",
  },
  {
    value: "engineering",
    label: "Engineering",
    description: "Product interfaces and implementation.",
  },
  {
    value: "product",
    label: "Product",
    description: "Discovery, delivery and strategy.",
  },
];

export function SignupFormDemo() {
  const {
    control,
    register,
    handleSubmit,
    formState: { dirtyFields, errors, isSubmitting, isSubmitSuccessful },
  } = useForm<SignupValues>({
    resolver: zodResolver(signupSchema),
    defaultValues: {
      firstName: "",
      lastName: "",
      email: "",
      phone: "",
      password: "",
      confirmPassword: "",
      role: "",
      note: "",
      terms: false,
    },
    mode: "onBlur",
  });

  async function onSubmit() {
    await new Promise((resolve) => window.setTimeout(resolve, 1000));
  }

  return (
    <form
      onSubmit={handleSubmit(onSubmit)}
      className="mx-auto w-full max-w-3xl"
      noValidate
    >
      <FormSection
        title="Create your account"
        description="A focused onboarding form with secure validation and compact inset surfaces."
        footer={
          <>
            <p className="text-xs text-muted-foreground" aria-live="polite">
              {isSubmitSuccessful
                ? "Account details validated successfully."
                : "Your information is securely reviewed before submission."}
            </p>
            <Button
              type="submit"
              loading={isSubmitting}
              loadingText="Creating account..."
            >
              Create account
            </Button>
          </>
        }
      >
        <FormGrid gap="compact">
          <InputField
            label="First name"
            placeholder="Sofia"
            required
            error={errors.firstName?.message}
            {...register("firstName")}
          />
          <InputField
            label="Last name"
            placeholder="Matos"
            required
            error={errors.lastName?.message}
            {...register("lastName")}
          />
          <InputField
            type="email"
            label="Email"
            placeholder="sofia@company.com"
            required
            state={dirtyFields.email && !errors.email ? "success" : "default"}
            error={errors.email?.message}
            {...register("email")}
          />
          <InputField
            type="tel"
            label="Phone"
            placeholder="+55 11 99999-9999"
            required
            error={errors.phone?.message}
            {...register("phone")}
          />
          <Controller
            name="password"
            control={control}
            render={({ field }) => (
              <PasswordInput
                label="Password"
                required
                value={field.value}
                name={field.name}
                onBlur={field.onBlur}
                onChange={field.onChange}
                ref={field.ref}
                error={errors.password?.message}
                placeholder="Create a password"
              />
            )}
          />
          <Controller
            name="confirmPassword"
            control={control}
            render={({ field }) => (
              <PasswordInput
                label="Confirm password"
                required
                value={field.value}
                name={field.name}
                onBlur={field.onBlur}
                onChange={field.onChange}
                ref={field.ref}
                error={errors.confirmPassword?.message}
                placeholder="Repeat your password"
                showCriteria={false}
              />
            )}
          />
          <FormGridItem span="full">
            <Controller
              name="role"
              control={control}
              render={({ field }) => (
                <Select
                  label="Role or interest"
                  required
                  options={roleOptions}
                  value={field.value || null}
                  onValueChange={(nextValue) => field.onChange(nextValue ?? "")}
                  name={field.name}
                  inputRef={field.ref}
                  error={errors.role?.message}
                  placeholder="Choose an area"
                />
              )}
            />
          </FormGridItem>
          <FormGridItem span="full">
            <TextareaField
              label="Additional notes"
              description="Optional. Tell us what you want to build."
              placeholder="A short note about your next product..."
              error={errors.note?.message}
              {...register("note")}
            />
          </FormGridItem>
          <FormGridItem span="full">
            <Controller
              name="terms"
              control={control}
              render={({ field }) => (
                <CheckboxField
                  label="I agree to the workspace terms"
                  description="Required before creating an account."
                  checked={field.value}
                  onCheckedChange={field.onChange}
                  name={field.name}
                  inputRef={field.ref}
                  error={errors.terms?.message}
                  required
                />
              )}
            />
          </FormGridItem>
        </FormGrid>
      </FormSection>
    </form>
  );
}