AutoForm
React

FAQ & Examples

Common questions and examples.

How do I show validation errors in real time and disable submit until valid?

Create a formControl with createFormControl and set the validation mode to all. Access the isValid formState via useFormContext to track validation status and disable the submit button accordingly.

Real-time validation
"use client";

import * as React from "react";
import { createFormControl, useFormContext } from "react-hook-form";
import * as z from "zod";
import { ZodProvider } from "@acp-autoform/zod";

import { AutoForm } from "@/components/ui/autoform";
import { Button } from "@/components/ui/button";

const realtimeSchema = z.object({
  email: z.email("Enter a valid email"),
  password: z.string().min(8, "Use at least 8 characters"),
});

type RealtimeValues = z.infer<typeof realtimeSchema>;

const schemaProvider = new ZodProvider(realtimeSchema);

const SubmitButton = () => {
  const {
    formState: { isValid },
  } = useFormContext();

  return (
    <Button type="submit" className="mt-4" disabled={!isValid}>
      Create Account
    </Button>
  );
};

export function RealtimeValidationDemo() {
  const { formControl } = React.useMemo(
    () =>
      createFormControl<RealtimeValues>({
        mode: "all",
      }),
    [],
  );
  const [values, setValues] = React.useState<RealtimeValues | null>(null);

  return (
    <div className="grid gap-4 rounded-lg border bg-background p-6 ">
      <AutoForm
        schema={schemaProvider}
        formControl={formControl}
        onSubmit={(values) => setValues(values)}
      >
        <SubmitButton />
        {values && (
          <div className="rounded-md border bg-muted p-4">
            <pre className="text-sm">
              <code className="bg-transparent border-none">
                {JSON.stringify(values, null, 2)}
              </code>
            </pre>
          </div>
        )}
      </AutoForm>
    </div>
  );
}

How do I submit, reset AutoForm from buttons in a Dialog?

Use either approach:

  1. Autoform wraps the form with FormProvider. Render the dialog components as AutoForm children and use the useFormContext hook to access form methods and states.

  2. Access form methods and states externally with createFormControl and trigger actions like submission, reset etc.

Dialog submit
"use client";

import * as React from "react";
import { CheckCircle, Circle } from "lucide-react";
import { useFormContext } from "react-hook-form";
import * as z from "zod";
import { fieldConfig, ZodProvider } from "@acp-autoform/zod";

import { AutoForm } from "@/components/ui/autoform";
import { Button, buttonVariants } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";
import type { VariantProps } from "class-variance-authority";

const dialogSchema = z.object({
  username: z.string().min(2, "Enter at least 2 characters"),
  action: z.enum(["create", "read", "update", "delete"]).superRefine(
    fieldConfig({
      label: "Action",
      inputProps: {
        placeholder: "Select an action",
      },
    }),
  ),
});

const schemaProvider = new ZodProvider(dialogSchema);

interface ResetButtonProps
  extends
    React.ButtonHTMLAttributes<HTMLButtonElement>,
    VariantProps<typeof buttonVariants> {
  asChild?: boolean;
  resetVal: Record<string, unknown>;
}

const ResetButton = React.forwardRef<HTMLButtonElement, ResetButtonProps>(
  ({ resetVal, onClick, ...props }, ref) => {
    const { reset } = useFormContext();

    return (
      <Button
        onClick={(event: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
          reset(resetVal);
          onClick?.(event);
        }}
        ref={ref}
        {...props}
      />
    );
  },
);
ResetButton.displayName = "ResetButton";

export default function ExternalDialogSubmitDemo() {
  const [submitted, setSubmitted] = React.useState<z.infer<
    typeof dialogSchema
  > | null>(null);

  return (
    <div className="flex min-h-72 items-center justify-center rounded-lg border bg-background p-6">
      <Dialog>
        <DialogTrigger asChild>
          <Button>Open dialog form</Button>
        </DialogTrigger>
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle>Edit permission</DialogTitle>
            <DialogDescription>
              The submit and reset buttons are rendered as AutoForm children.
            </DialogDescription>
          </DialogHeader>
          <AutoForm
            schema={schemaProvider}
            onSubmit={(data, form) => {
              setSubmitted(data);
              form.reset({ username: "", action: undefined });
            }}
            defaultValues={{ username: "", action: "" }}
          >
            <Button type="submit" className="w-full gap-2">
              <CheckCircle className="h-4 w-4" />
              Save
            </Button>
            <ResetButton
              type="button"
              variant="outline"
              className="w-full gap-2"
              resetVal={{ username: "", action: undefined }}
            >
              <Circle className="h-4 w-4" />
              Reset
            </ResetButton>
          </AutoForm>
          {submitted && (
            <pre className="overflow-auto rounded-md bg-secondary p-3 text-xs">
              {JSON.stringify(submitted, null, 2)}
            </pre>
          )}
        </DialogContent>
      </Dialog>
    </div>
  );
}

How do I add a slider, color picker, and many different custom components to a form?

Register the custom input using useController, pass it to the formComponents prop of Autoform, and mark the matching schema field with fieldConfig({ fieldType: "..." }). AutoForm routes each component to the right field.

Custom fields form
Banana#6366f1
50
Submitted value
null
"use client";

import * as React from "react";
import { fieldConfig, ZodProvider } from "@acp-autoform/zod";
import * as z from "zod";

import { AutoForm } from "@/components/ui/autoform";
import {
  ColorPickerField,
  DatePickerField,
  FileUploadField,
  NumberStepperField,
  RadioCardField,
  SliderField,
} from "@/components/custom-field-components";

const schema = z.object({
  quantity: z
    .number()
    .min(1)
    .max(99)
    .default(1)
    .describe("Quantity")
    .check(fieldConfig({ fieldType: "numberStepper" })),

  themeColor: z
    .string()
    .regex(/^#[0-9a-fA-F]{6}$/, "Pick a valid color")
    .default("#6366f1")
    .describe("Find the color")
    .check(fieldConfig({ fieldType: "colorPicker" })),

  birthdate: z
    .string()
    .min(1, "Pick a date")
    .describe("Birthdate")
    .check(fieldConfig({ fieldType: "dateTime" })),

  avatar: z
    .instanceof(File)
    .optional()
    .describe("Profile picture")
    .check(fieldConfig({ fieldType: "fileUpload" })),

  volume: z
    .number()
    .min(0)
    .max(100)
    .default(50)
    .check(fieldConfig({ fieldType: "slider" })),

  plan: z
    .enum(["starter", "pro", "enterprise"])
    .default("starter")
    .describe("Plan")
    .check(
      fieldConfig({
        fieldType: "radioCard",
        customData: {
          options: [
            { id: "starter", label: "Starter", desc: "Up to 3 projects" },
            { id: "pro", label: "Pro", desc: "Up to 100 projects" },
            { id: "enterprise", label: "Enterprise", desc: "Custom limits" },
          ],
        },
      }),
    ),
});

const schemaProvider = new ZodProvider(schema);

type FormValues = z.infer<typeof schema>;

export function CustomFieldsDemo() {
  const [result, setResult] = React.useState<FormValues | null>(null);

  return (
    <div className="rounded-lg border bg-background p-6 space-y-6">
      <AutoForm
        schema={schemaProvider}
        formComponents={{
          numberStepper: NumberStepperField,
          radioCard: RadioCardField,
          dateTime: DatePickerField,
          slider: SliderField,
          colorPicker: ColorPickerField,
          fileUpload: FileUploadField,
        }}
        formProps={{ className: "flex flex-col gap-6" }}
        onSubmit={(data) => setResult(data)}
        withSubmit
      />

      <div className="rounded-md border bg-secondary/50 p-4 text-sm">
        <div className="font-medium mb-2">Submitted value</div>
        <pre className="overflow-auto text-xs">
          {result
            ? JSON.stringify(
                {
                  quantity: result.quantity,
                  plan: result.plan,
                  birthdate: result.birthdate,
                  volume: result.volume,
                  themeColor: result.themeColor,
                  avatar: result.avatar?.name ?? null,
                },
                null,
                2,
              )
            : "null"}
        </pre>
      </div>
    </div>
  );
}

How do I create fields with dependencies between them?

1. Validation — use Zod's refinement functions (superRefine, refine, or check) to express cross-field rules. These run on submission and integrate with AutoForm's resolver, so error messages are placed on the correct fields automatically.

2. UI behaviour — Control rendering and interactivity by using React Hook Form's useWatch and useFormContext inside a custom field component or a fieldWrapper. AutoForm wraps every form in a FormProvider, making these hooks work anywhere inside the tree.

PatternExample in the demo below
Disable a fieldGift message — visible but disabled until This is a gift is checked
Hide a fieldCoupon code — invisible until "Have a coupon?" is checked
Conditional validationCard details — required by superRefine only when no 100% coupon is applied
Dynamic optionsState dropdown — list rebuilds every time the country changes
Ecommerce checkout

Payment

"use client";

import * as React from "react";
import * as z from "zod";
import { fieldConfig, ZodProvider } from "@acp-autoform/zod";

import { AutoForm } from "@/components/ui/autoform";
import {
  CountrySelectField,
  StateSelectField,
  CouponCodeFieldWrapper,
  GiftMessageField,
  PaymentFieldWrapper,
} from "@/components/ecommerce-checkout-fields";

const schema = z
  .object({
    country: z
      .string()
      .min(1, "Country is required")
      .describe("Country")
      .check(fieldConfig({ fieldType: "country" })),

    state: z
      .string()
      .min(1, "State / province is required")
      .describe("State / Province")
      .check(fieldConfig({ fieldType: "state" })),

    haveCoupon: z
      .boolean()
      .optional()
      .default(false)
      .describe("Have a coupon?"),

    couponCode: z
      .string()
      .optional()
      .describe("Coupon code")
      .check(
        fieldConfig({
          fieldWrapper: CouponCodeFieldWrapper,
          inputProps: { placeholder: "Try free100" },
        }),
      ),

    isGift: z.boolean().optional().default(false).describe("This is a gift"),

    giftMessage: z
      .string()
      .optional()
      .describe("Gift message")
      .check(fieldConfig({ fieldType: "giftMessage" })),

    payment: z
      .object({
        cardNumber: z
          .string()
          .optional()
          .describe("Card number")
          .check(
            fieldConfig({ inputProps: { placeholder: "1234 5678 9012 3456" } }),
          ),

        expiryDate: z
          .string()
          .optional()
          .describe("Expiry (MM/YY)")
          .check(fieldConfig({ inputProps: { placeholder: "MM/YY" } })),

        cvv: z
          .string()
          .min(3)
          .max(4)
          .optional()
          .describe("CVV")
          .check(fieldConfig({ inputProps: { placeholder: "123" } })),
      })
      .check(fieldConfig({ fieldWrapper: PaymentFieldWrapper })), // for a discount banner when FREE100 is active.
  })
  .superRefine((data, ctx) => {
    if (data.haveCoupon && !data.couponCode?.trim()) {
      ctx.addIssue({
        code: "custom",
        message: "Coupon code is required",
        path: ["couponCode"],
      });
    }

    const isFree =
      data.haveCoupon && data.couponCode?.toUpperCase() === "FREE100";

    if (!isFree) {
      if (!data.payment?.cardNumber?.trim())
        ctx.addIssue({
          code: "custom",
          message: "Card number is required",
          path: ["payment", "cardNumber"],
        });
      if (!data.payment?.expiryDate?.trim())
        ctx.addIssue({
          code: "custom",
          message: "Expiry date is required",
          path: ["payment", "expiryDate"],
        });
      if (!data.payment?.cvv?.trim())
        ctx.addIssue({
          code: "custom",
          message: "CVV is required",
          path: ["payment", "cvv"],
        });
    }
  });

type CheckoutValues = z.infer<typeof schema>;

const schemaProvider = new ZodProvider(schema);

export function EcommerceCheckoutDemo() {
  const [result, setResult] = React.useState<CheckoutValues | null>(null);

  return (
    <div className="rounded-lg border bg-background p-6 space-y-6">
      <AutoForm
        schema={schemaProvider}
        formComponents={{
          country: CountrySelectField,
          state: StateSelectField,
          giftMessage: GiftMessageField,
        }}
        onSubmit={(data) => setResult(data as CheckoutValues)}
        withSubmit
      />

      {result && (
        <div className="rounded-md border bg-secondary/50 p-4 text-sm">
          <div className="font-medium mb-2">Submitted value</div>
          <pre className="overflow-auto text-xs">
            {JSON.stringify(result, null, 2)}
          </pre>
        </div>
      )}
    </div>
  );
}

How do I create a simple multistep form?

Use a separate schema for each step. Render the current step, call trigger() to validate it, then use getValues() to read and save its values in state before moving forward. Since each step is its own AutoForm instance, validation is isolated to the current step.

Multistep form
ContactAccountAddressPreferences
Submitted value
null
"use client";

import * as React from "react";
import { FieldValues } from "react-hook-form";
import * as z from "zod";

import { MultistepForm, MultistepFormStep } from "@/components/multistep-form";

// ---------------------------------------------------------------------------
// Step definitions
// ---------------------------------------------------------------------------

const STEPS: MultistepFormStep[] = [
  {
    label: "Contact",
    schema: z.object({
      fullName: z.string().min(2, "Enter your name"),
      email: z.email("Enter a valid email"),
    }),
    defaults: { fullName: "", email: "" },
  },
  {
    label: "Account",
    schema: z.object({
      username: z.string().min(3, "Use at least 3 characters"),
      password: z.string().min(8, "Use at least 8 characters"),
    }),
    defaults: { username: "", password: "" },
  },
  {
    label: "Address",
    schema: z.object({
      street: z.string().min(3, "Enter your street address"),
      city: z.string().min(2, "Enter your city"),
    }),
    defaults: { street: "", city: "" },
  },
  {
    label: "Preferences",
    schema: z.object({
      newsletter: z.boolean().default(false),
      notes: z.string().min(5, "Add a short note"),
    }),
    defaults: { newsletter: false, notes: "" },
  },
];

// ---------------------------------------------------------------------------
// Demo uses MultistepForm
// ---------------------------------------------------------------------------

export default function MultistepFormDemo() {
  const [submitted, setSubmitted] = React.useState<FieldValues | null>(null);

  return (
    <div className="grid gap-4 rounded-lg border bg-background p-6 md:grid-cols-[1fr_220px]">
      <MultistepForm steps={STEPS} onSubmit={setSubmitted} />

      <div className="rounded-md border bg-secondary/50 p-4 text-sm">
        <div className="font-medium">Submitted value</div>
        <pre className="mt-3 overflow-auto text-xs">
          {JSON.stringify(submitted, null, 2)}
        </pre>
      </div>
    </div>
  );
}

How do I use another AutoForm inside an AutoForm field?

Define a custom field component, register it in formComponents, then mark the schema field with fieldConfig({ fieldType: "..." }). When the nested AutoForm submits, pass its value back to the parent via onChange.

Nested AutoForm
Submitted value
null
"use client";
import { useController } from "react-hook-form";

import * as React from "react";
import type { AutoFormFieldProps } from "@acp-autoform/react";
import { fieldConfig, ZodProvider } from "@acp-autoform/zod";
import * as z from "zod";

import { AutoForm } from "@/components/ui/autoform";
import { Button } from "@/components/ui/button";
import {
  Dialog,
  DialogContent,
  DialogDescription,
  DialogHeader,
  DialogTitle,
  DialogTrigger,
} from "@/components/ui/dialog";

const colorsSchema = z.object({
  colors: z.array(z.string().min(1, "Enter a color")).min(1),
});

const profileSchema = z.object({
  username: z.string().min(2, "Enter a username"),
  colors: z
    .array(z.string())
    .min(1, "Choose at least one color")
    .default([])
    .describe("Favorite colors")
    .check(fieldConfig({ fieldType: "colorDialog" })),
});

const colorsProvider = new ZodProvider(colorsSchema);
const profileProvider = new ZodProvider(profileSchema);

function ColorDialogField({ id }: AutoFormFieldProps) {
  const [open, setOpen] = React.useState(false);
  const { field } = useController({ name: id });
  const colors = Array.isArray(field.value) ? field.value : [];

  return (
    <div className="space-y-3">
      {colors.length > 0 && (
        <div className="flex flex-wrap gap-2">
          {colors.map((color, index) => (
            <span
              className="rounded-md border bg-secondary px-2 py-1 text-xs"
              key={`${color}-${index}`}
            >
              {color}
            </span>
          ))}
        </div>
      )}
      <Dialog open={open} onOpenChange={setOpen}>
        <DialogTrigger asChild>
          <Button type="button" variant="outline" size="sm">
            Add colors
          </Button>
        </DialogTrigger>
        <DialogContent className="sm:max-w-md">
          <DialogHeader>
            <DialogTitle>Pick favorite colors</DialogTitle>
            <DialogDescription>
              This nested AutoForm writes its value back to the parent field.
            </DialogDescription>
          </DialogHeader>
          <AutoForm
            schema={colorsProvider}
            values={{ colors }}
            onSubmit={(data) => {
              field.onChange(data.colors);
              setOpen(false);
            }}
          >
            <Button type="submit">Save Colors</Button>
          </AutoForm>
        </DialogContent>
      </Dialog>
    </div>
  );
}

export default function NestedAutoFormDemo() {
  const [result, setResult] = React.useState<z.infer<
    typeof profileSchema
  > | null>(null);

  return (
    <div className="grid gap-4 rounded-lg border bg-background p-6 md:grid-cols-[1fr_220px]">
      <AutoForm
        schema={profileProvider}
        formComponents={{ colorDialog: ColorDialogField }}
        onSubmit={(data) => setResult(data)}
        withSubmit
      />
      <div className="rounded-md border bg-secondary/50 p-4 text-sm">
        <div className="font-medium">Submitted value</div>
        <pre className="mt-3 overflow-auto text-xs">
          {JSON.stringify(result, null, 2)}
        </pre>
      </div>
    </div>
  );
}

How do I create a interactive, dynamic form builder?

Combine a Monaco editor with AutoForm for interactive forms: evaluate the Zod schema string on every editor change and pass the resulting ZodProvider to AutoForm. AutoForm re-renders the form instantly whenever the schema updates.

Interactive form builder
Loading editor…
"use client";

import React, { useEffect, useState } from "react";
import Editor from "@monaco-editor/react";
import { z } from "zod";
import { ZodProvider } from "@acp-autoform/zod";
import { SchemaProvider } from "@acp-autoform/core";

import { AutoForm } from "@/components/ui/autoform";

const editorOptions = {
  minimap: { enabled: false },
  scrollBeyondLastLine: false,
  lineNumbersMinChars: 2,
  glyphMargin: false,
  folding: false,
  scrollbar: {
    useShadows: false,
    verticalScrollbarSize: 10,
    horizontalScrollbarSize: 10,
    alwaysConsumeMouseWheel: false,
  },
};

const getEditorTheme = () =>
  document.documentElement.classList.contains("dark") ? "vs-dark" : "light";

function useEditorTheme() {
  const [theme, setTheme] = useState(getEditorTheme);
  useEffect(() => {
    const observer = new MutationObserver(() => setTheme(getEditorTheme()));
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    });
    return () => observer.disconnect();
  }, []);
  return theme;
}

const defaultCode = `z.object({
  name: z.string(),
  age: z.coerce.number(),
  isHuman: z.boolean(),
})`;

const globalZod = z;

export default function InteractiveSchemaDemoContent() {
  const [code, setCode] = React.useState(defaultCode);
  const [schemaProvider, setSchemaProvider] = React.useState<SchemaProvider>(
    () =>
      new ZodProvider(
        z.object({
          name: z.string(),
          age: z.coerce.number(),
          isHuman: z.boolean(),
        }),
      ),
  );
  const [formKey, setFormKey] = React.useState(0);
  const [data, setData] = useState("");
  const editorTheme = useEditorTheme();

  useEffect(() => {
    try {
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
      const z = globalZod;
      const parsedSchema = eval(code); // Warning: eval is unsafe. Do not use with untrusted input.
      const provider = new ZodProvider(parsedSchema);
      provider.parseSchema();
      setSchemaProvider(provider);
      setFormKey((k) => k + 1);
    } catch (error) {
      console.error(error);
    }
  }, [code]);

  return (
    <div className="grid md:grid-cols-2 gap-1 w-full rounded-lg border bg-background overflow-hidden">
      <div className="bg-muted/40 p-1 md:py-4 md:px-1 border-b md:border-b-0 md:border-r">
        <Editor
          className="md:h-95 md:border-0 h-65 border"
          options={editorOptions}
          defaultLanguage="javascript"
          defaultValue={defaultCode}
          theme={editorTheme}
          onChange={(value) => setCode(value || "")}
        />
      </div>

      <div className="p-6 pb-8">
        <AutoForm
          key={formKey}
          schema={schemaProvider}
          onSubmit={(data) => setData(JSON.stringify(data, null, 2))}
          withSubmit
        />

        {data && (
          <pre className="bg-muted rounded-md p-4 text-sm mt-4 overflow-auto">
            {data}
          </pre>
        )}
      </div>
    </div>
  );
}

Caution

This demo uses eval() to parse the editor input. Avoid using eval() with untrusted input or in production server-side code.

On this page