Hooks
useForm
A custom React hook for form state management and validation
Component Source'use client'import * as z from 'zod'import { Button } from '@/components/ui/button'import { Field, FieldDescription, FieldError, FieldGroup, FieldLabel, FieldLegend, FieldSet,} from '@/components/ui/field'import { Input } from '@/components/ui/input'import { useForm } from '@/registry/hooks/use-form'import { toast } from '@/components/ui/toast'const formSchema = z.object({ name: z.string().min(2, 'Name must be at least 2 characters long'), age: z .number('Age must be a number') .min(7, 'Age nust be between 7 and 12') .max(12, 'Age nust be between 7 and 12'),})export default function UseFormDemo() { const { formId, FormField, handleSubmit, state } = useForm({ defaultValues: { name: '', age: 0 }, schema: formSchema, onSubmit: (data) => { toast.add({ title: 'Form submitted successfully!', description: ( <pre className='w-full rounded-md bg-input p-2 text-foreground'> {JSON.stringify(data, null, 2)} </pre> ), }) }, }) return ( <form id={formId} className='rounded-xl border bg-card p-6 text-card-foreground shadow-sm' onSubmit={handleSubmit} > <FieldSet> <FieldLegend>Login Form</FieldLegend> <FieldDescription> A simple login form example using Yuki UI and Zod for validation. </FieldDescription> <FieldGroup> <FormField name='name' render={({ meta, field }) => ( <Field data-invalid={meta.errors.length > 0}> <FieldLabel htmlFor={field.id}>Name</FieldLabel> <Input {...field} placeholder='Enter your name' /> <FieldError id={meta.errorId} errors={meta.errors} /> </Field> )} /> <FormField name='age' render={({ meta, field }) => ( <Field data-invalid={meta.errors.length > 0}> <FieldLabel htmlFor={field.id}>Age</FieldLabel> <Input {...field} type='number' placeholder='Enter your age' /> <FieldError id={meta.errorId} errors={meta.errors} /> </Field> )} /> <Field> <Button type='submit' disabled={state.isPending}> Submit </Button> </Field> </FieldGroup> </FieldSet> </form> )}Installation
CLI
npx shadcn add https://yuki-ui.vercel.app/r/use-form.jsonnpx shadcn add https://yuki-ui.vercel.app/r/use-form.jsonpnpm dlx shadcn add https://yuki-ui.vercel.app/r/use-form.jsonbunx --bun shadcn add https://yuki-ui.vercel.app/r/use-form.jsonManual
Copy and paste the following code into your project.
import * as React from 'react'interface FormError { message: string | null issues?: StandardSchemaV1.Issue[]}type OnChangeParam<TValue> = | React.ChangeEvent< HTMLInputElement | HTMLTextAreaElement | HTMLSelectElement > | TValueinterface FormFieldProps<TName extends keyof TValues, TValues> { name: TName render: (props: { field: { id: string name: TName value: TValues[TName] onChange: (params: OnChangeParam<TValues[TName]>) => void onBlur: () => void // Accessibility attributes form: string 'aria-describedby': string 'aria-invalid': boolean } meta: { descriptionId: string errorId: string errors: StandardSchemaV1.Issue[] isPending: boolean } }) => React.ReactNode}function extractError(errors: StandardSchemaV1.Issue[], name: string) { return errors.filter((issue) => { if (!issue.path || issue.path.length === 0) return false const firstPath = issue.path[0] if (typeof firstPath === 'object' && 'key' in firstPath) return firstPath.key === name return firstPath === name })}export function useForm< TValues, TData, TError extends FormError, TSchema extends | StandardSchemaV1 | ((values: TValues) => TResults | Promise<TResults>), TResults extends StandardSchemaV1.Result<TValues>,>(props: { defaultValues: TValues schema?: TSchema onSubmit: (data: TValues) => TData | Promise<TData> onSuccess?: (data: TData) => unknown | Promise<unknown> onError?: (error: TError) => unknown | Promise<unknown>}): { formId: string FormField: <TName extends keyof TValues>( props: FormFieldProps<TName, TValues>, ) => React.ReactNode handleSubmit: (event?: React.FormEvent) => void state: { values: TValues data: TData | null error: TError | null isPending: boolean }} { const { defaultValues, schema, onSubmit, onSuccess, onError } = props const formId = React.useId() const formValuesRef = React.useRef<TValues>(defaultValues) const formDataRef = React.useRef<TData | null>(null) const formErrorRef = React.useRef<TError | null>(null) const [isPending, startTransition] = React.useTransition() const setFormValue = React.useCallback( <TKey extends keyof TValues>(field: TKey, value: TValues[TKey]) => { formValuesRef.current = { ...formValuesRef.current, [field]: value } }, [], ) const validate = React.useCallback( async (values: TValues): Promise<TValues> => { if (!schema) return values let result if (typeof schema === 'function') result = await schema(values) else result = await schema['~standard'].validate(values) if ('issues' in result) throw result.issues return (result.value ?? result) as TValues }, [schema], ) const handleSubmit = React.useCallback( (event?: React.FormEvent) => { event?.preventDefault() event?.stopPropagation() formDataRef.current = null formErrorRef.current = null startTransition(async () => { try { const validValues = await validate(formValuesRef.current) formValuesRef.current = validValues const result = await onSubmit(validValues) formDataRef.current = result ?? null await onSuccess?.(result) } catch (error) { let issues: FormError['issues'] if (Array.isArray(error)) issues = error let message = 'Validate failed' if (error instanceof Error) message = error.message formErrorRef.current = { message, issues } as TError await onError?.(formErrorRef.current) } }) }, [onSubmit, onSuccess, onError, validate], ) const FormField = React.useCallback( function FormField<TName extends keyof TValues>({ name, render, }: FormFieldProps<TName, TValues>) { const id = React.useId() const [value, setValue] = React.useState( () => formValuesRef.current[name], ) const prevValueRef = React.useRef(value) const [errors, setErrors] = React.useState<StandardSchemaV1.Issue[]>(() => extractError(formErrorRef.current?.issues ?? [], name as string), ) const onChange = React.useCallback( (param: OnChangeParam<TValues[TName]>) => { if (param === null) return setErrors([]) let newValue if (typeof param === 'object' && 'target' in param) { const target = param.target as HTMLInputElement if (target.type === 'checkbox') newValue = target.checked else if (target.type === 'number') newValue = isNaN(target.valueAsNumber) ? 0 : target.valueAsNumber else newValue = target.value } else newValue = param as TValues[TName] setValue(newValue as TValues[TName]) setFormValue(name, newValue as TValues[TName]) }, [name], ) const onBlur = React.useCallback(async () => { if (prevValueRef.current === value) return prevValueRef.current = value try { const result = await validate({ ...formValuesRef.current, [name]: value, }) setFormValue(name, result[name]) } catch (error) { if (!Array.isArray(error)) return setErrors(extractError(error, name as string)) } }, [name, value]) const meta = React.useMemo( () => ({ descriptionId: `form-${formId}-field-${id}-description`, errorId: `form-${formId}-field-${id}-error`, errors, isPending, }), [id, errors], ) return render({ field: { id: `form-${formId}-field-${id}`, name, value, onChange, onBlur, form: `form-${formId}`, 'aria-describedby': meta.errors.length > 0 ? `${meta.descriptionId} ${meta.errorId}` : meta.descriptionId, 'aria-invalid': meta.errors.length > 0, }, meta, }) }, [formId, setFormValue, validate, isPending], ) return React.useMemo( () => ({ formId: `form-${formId}`, FormField, handleSubmit, state: { get values() { return formValuesRef.current }, get data() { return formDataRef.current }, get error() { return formErrorRef.current }, get isPending() { return isPending }, }, }), [formId, FormField, handleSubmit, isPending], )}/** The Standard Schema interface. */interface StandardSchemaV1<Input = unknown, Output = Input> { /** The Standard Schema properties. */ readonly '~standard': StandardSchemaV1.Props<Input, Output>}declare namespace StandardSchemaV1 { /** The Standard Schema properties interface. */ export interface Props<Input = unknown, Output = Input> { /** The version number of the standard. */ readonly version: 1 /** The vendor name of the schema library. */ readonly vendor: string /** Validates unknown input values. */ readonly validate: ( value: unknown, options?: StandardSchemaV1.Options | undefined, ) => Result<Output> | Promise<Result<Output>> /** Inferred types associated with the schema. */ readonly types?: Types<Input, Output> | undefined } /** The result interface of the validate function. */ export type Result<Output> = SuccessResult<Output> | FailureResult /** The result interface if validation succeeds. */ export interface SuccessResult<Output> { /** The typed output value. */ readonly value: Output /** A falsy value for `issues` indicates success. */ readonly issues?: undefined } export interface Options { /** Explicit support for additional vendor-specific parameters, if needed. */ readonly libraryOptions?: Record<string, unknown> | undefined } /** The result interface if validation fails. */ export interface FailureResult { /** The issues of failed validation. */ readonly issues: ReadonlyArray<Issue> } /** The issue interface of the failure output. */ export interface Issue { /** The error message of the issue. */ readonly message: string /** The path of the issue, if any. */ readonly path?: ReadonlyArray<PropertyKey | PathSegment> | undefined } /** The path segment interface of the issue. */ export interface PathSegment { /** The key representing a path segment. */ readonly key: PropertyKey } /** The Standard Schema types interface. */ export interface Types<Input = unknown, Output = Input> { /** The input type of the schema. */ readonly input: Input /** The output type of the schema. */ readonly output: Output } /** Infers the input type of a Standard Schema. */ export type InferInput<Schema extends StandardSchemaV1> = NonNullable< Schema['~standard']['types'] >['input'] /** Infers the output type of a Standard Schema. */ export type InferOutput<Schema extends StandardSchemaV1> = NonNullable< Schema['~standard']['types'] >['output']}Note:
You also need to install the Field components after installing the Form component.
Usage
Create a form schema
Define your form validation schema using one of the supported validation libraries. This schema will validate your form data and provide type safety.
import * as z from 'zod/v4'
const formSchema = z.object({
name: z.string().min(1),
})import { type } from 'arktype'
const formSchema = type({
name: 'string>1',
})import * as v from 'valibot'
const formSchema = v.object({
name: v.pipe(v.string(), v.minLength(1)),
})function formSchema(value: { name: string }) {
if (value.name.length < 1)
return { issues: [{ path: ['name'], message: 'Name is required' }] }
return { value }
}Define a form
Set up your form component using the useForm hook. Configure default values, attach your validation schema, and define the submit handler.
import { useForm } from '@/components/ui/form'
export function MyForm() {
const form = useForm({
defaultValues: { name: '' },
validator: formSchema,
onSubmit: (data) => {
console.log('Form submitted:', data)
},
})
return <form></form>
}Build your form UI
Create the form structure with fields, labels, inputs, and validation messages. Use the form's Field component to handle state management and validation automatically.
import { Button } from '@/components/ui/button'
import {
Field,
FieldDescription,
FieldError,
FieldGroup,
FieldLabel,
FieldLegend,
FieldSet,
} from '@/components/ui/field'
import { FormField, useForm } from '@/components/ui/form'
import { Input } from '@/components/ui/input'
export function MyForm() {
const form = useForm({
...
})
return (
<form onSubmit={form.handleSubmit}>
<FieldSet>
<FieldLegend>...</FieldLegend>
<FieldDescription>...</FieldDescription>
<FieldGroup>
<FormField
control={form.control}
name='name'
render={({ meta, field, state }) => (
<Field data-invalid={state.hasError}>
<FieldLabel htmlFor={meta.fieldId}>Name</FieldLabel>
<Input {...field} />
<FieldError id={meta.errorId} errors={state.errors} />
</Field>
)}
/>
<Button type='submit' disabled={form.formState.isSubmitting}>
Submit
</Button>
</FieldGroup>
</FieldSet>
</form>
)
}