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'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 form = useForm({ defaultValues: { name: '', age: 0 }, schema: formSchema, onSubmit: (data) => { console.log('Form submitted:', data) }, }) return ( <form className='rounded-xl border bg-card p-6 text-card-foreground shadow-sm' onSubmit={form.handleSubmit} > <FieldSet> <FieldLegend>Login Form</FieldLegend> <FieldDescription> A simple login form example using Yuki UI and Zod for validation. </FieldDescription> <FieldGroup> <form.Field name='name' render={({ meta, field }) => ( <Field data-invalid={meta.errors.length > 0}> <FieldLabel htmlFor={meta.fieldId}>Name</FieldLabel> <Input {...field} placeholder='Enter your name' /> <FieldError id={meta.errorId} errors={meta.errors} /> </Field> )} /> <form.Field name='age' render={({ meta, field }) => ( <Field data-invalid={meta.errors.length > 0}> <FieldLabel htmlFor={meta.fieldId}>Age</FieldLabel> <Input {...field} type='number' placeholder='Enter your age' /> <FieldError id={meta.errorId} errors={meta.errors} /> </Field> )} /> <Field> <Button type='submit' disabled={form.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.
'use client'import * as React from 'react'interface FormError<TValue extends Record<string, unknown>> { message: string | null errors?: Record<keyof TValue, StandardSchemaV1.Issue[]>}type ExtractValues<T extends StandardSchemaV1> = { [K in keyof Required<StandardSchemaV1.InferInput<T>>]: Required< StandardSchemaV1.InferInput<T> >[K]}interface RenderProps< TValue extends Record<string, unknown>, TFieldName extends keyof TValue,> { meta: { fieldId: string descriptionId: string errorId: string errors: StandardSchemaV1.Issue[] isPending: boolean } field: { id: string name: TFieldName value: TValue[TFieldName] onChange: ( event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, ) => void onBlur: ( event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>, ) => Promise<void> 'aria-describedby': string 'aria-invalid': boolean }}const useForm = < TValues extends Record<string, unknown>, TData, TError extends FormError<TValues>, TSchema extends | StandardSchemaV1 | ((value: TValues) => TResults | Promise<TResults>), TResults extends StandardSchemaV1.Result<TValues>,>(opts: { defaultValues: TValues schema?: TSchema extends StandardSchemaV1 ? ExtractValues<TSchema> extends TValues ? TSchema : never : (value: TValues) => TResults | Promise<TResults> onSubmit: (data: TValues) => TData | Promise<TData> onSuccess?: (data: TData) => void onError?: (error: TError) => void}) => { const { defaultValues, schema, onSubmit, onSuccess, onError } = opts const valuesRef = React.useRef<TValues>(defaultValues) const dataRef = React.useRef<TData | null>(null) const errorRef = React.useRef<TError>({ message: null, errors: {} } as TError) const [isPending, startTransition] = React.useTransition() const getValues = React.useCallback(() => { return valuesRef.current }, []) const getData = React.useCallback(() => { return dataRef.current }, []) const getError = React.useCallback(() => { return errorRef.current }, []) const setValue = React.useCallback( <K extends keyof TValues>(key: K, value: TValues[K]) => { valuesRef.current = { ...valuesRef.current, [key]: value } }, [], ) const validateValues = React.useCallback( async ( values: TValues, ): Promise< | { success: true; data: TValues; error: null } | { success: false; data: null; error: TError } > => { if (!schema) return { success: true, data: values, error: null } let result: TResults if ('~standard' in schema) result = (await schema['~standard'].validate(values)) as TResults else result = await schema(values) if (result.issues) return { success: false, data: null, error: { message: 'Validation error', errors: result.issues.reduce< Record<string, StandardSchemaV1.Issue[]> >((acc, issue) => { if (!issue.path || issue.path.length === 0) return acc const key = typeof issue.path[0] === 'string' ? issue.path[0] : undefined if (!key) return acc acc[key] ??= [] acc[key].push(issue) return acc }, {}), } as TError, } return { success: true, data: result.value, error: null } }, [schema], ) const handleSubmit = React.useCallback( (e: React.FormEvent) => { e.preventDefault() e.stopPropagation() startTransition(async () => { dataRef.current = null errorRef.current = { message: null, errors: {} } as TError const { success, data, error } = await validateValues(valuesRef.current) if (!success) return void (errorRef.current = error) try { dataRef.current = await onSubmit(data) errorRef.current = { message: null, errors: {} } as TError return onSuccess?.(dataRef.current) } catch (e: unknown) { const message = e instanceof Error ? e.message : String(e) dataRef.current = null errorRef.current = { message, errors: {} } as TError return onError?.(errorRef.current) } }) }, [onSubmit, onSuccess, onError, validateValues], ) const Field = React.useCallback( function FormField<TFieldName extends keyof TValues>(props: { name: TFieldName render: (props: RenderProps<TValues, TFieldName>) => React.ReactNode }) { const [localValue, setLocalValue] = React.useState<TValues[TFieldName]>( valuesRef.current[props.name], ) const [errors, setErrors] = React.useState<StandardSchemaV1.Issue[]>( errorRef.current.errors?.[props.name] ?? [], ) const prevLocalValueRef = React.useRef(localValue) const handleChange = ( event: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>, ) => { event.persist() setErrors([]) let newValue const { type, checked, value, valueAsNumber } = event.target as unknown as HTMLInputElement if (type === 'checkbox') newValue = checked else if (type === 'number') newValue = isNaN(valueAsNumber) ? '' : valueAsNumber else newValue = value setLocalValue(newValue as TValues[TFieldName]) setValue(props.name, newValue as TValues[TFieldName]) } const handleBlur = async ( event: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>, ) => { event.persist() if (prevLocalValueRef.current === localValue) return prevLocalValueRef.current = localValue const { success, error } = await validateValues({ ...valuesRef.current, [props.name]: localValue, }) if (success) setValue(props.name, localValue) else setErrors(error.errors?.[props.name] ?? []) } return props.render({ meta: { fieldId: `${String(props.name)}-field`, descriptionId: `${String(props.name)}-description`, errorId: `${String(props.name)}-error`, errors, isPending, }, field: { id: `${String(props.name)}-field`, name: props.name, value: localValue, onChange: handleChange, onBlur: handleBlur, 'aria-invalid': errors.length > 0, 'aria-describedby': errors.length > 0 ? `${String(props.name)}-error ${String(props.name)}-description` : `${String(props.name)}-description`, }, }) }, [isPending, setValue, validateValues], ) return React.useMemo( () => ({ state: { getValues, getData, getError, isPending }, Field, setValue, handleSubmit, }), [Field, getData, getError, getValues, handleSubmit, isPending, setValue], )}export { useForm }/** The Standard Schema interface. */export interface StandardSchemaV1<Input = unknown, Output = Input> { /** The Standard Schema properties. */ readonly '~standard': StandardSchemaV1.Props<Input, Output>}// eslint-disable-next-line @typescript-eslint/no-namespaceexport 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, ) => 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 /** The non-existent issues. */ readonly issues?: undefined } /** The result interface if validation fails. */ export interface FailureResult { /** The issues of failed validation. */ readonly issues: readonly 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?: readonly (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>
)
}