Components
Form
A form component built from scratch that works with Standard Schema
Component Source'use client'import * as z from 'zod'import { Button } from '@/components/ui/button'import { Card, CardContent, CardDescription, CardHeader, CardTitle,} from '@/components/ui/card'import { Input } from '@/components/ui/input'import { FormControl, FormField, FormLabel, FormMessage, useForm,} from '@/components/ui/form'const formSchema = z.object({ email: z.email('Invalid email address'), password: z.string().min(6, 'Password must be at least 6 characters long'),})export default function FormDemo() { const form = useForm({ defaultValues: { email: '', password: '' }, validator: formSchema, onSubmit: (data) => { console.log('Form submitted:', data) }, }) return ( <Card className='min-w-md'> <CardHeader> <CardTitle>Login Form</CardTitle> <CardDescription> A simple login form example using Yuki UI and Zod for validation. </CardDescription> </CardHeader> <CardContent> <form className='grid gap-4' onSubmit={form.handleSubmit}> <FormField control={form.control} name='email' render={({ field }) => ( <div className='grid gap-2'> <FormLabel>Email</FormLabel> <FormControl {...field}> <Input type='email' placeholder='Enter your email' /> </FormControl> <FormMessage /> </div> )} /> <FormField control={form.control} name='password' render={({ field }) => ( <div className='grid gap-2'> <FormLabel>Password</FormLabel> <FormControl {...field}> <Input type='password' /> </FormControl> <FormMessage /> </div> )} /> <Button disabled={form.state.isPending}>Log in</Button> </form> </CardContent> </Card> )}
Installation
CLI
npx shadcn add https://ui.tiesen.id.vn/r/form.json
npx shadcn add https://ui.tiesen.id.vn/r/form.json
pnpm dlx shadcn add https://ui.tiesen.id.vn/r/form.json
bunx --bun shadcn add https://ui.tiesen.id.vn/r/form.json
Manual
Install the following dependencies:
npm install @radix-ui/react-slot
Copy and paste the following code into your project.
import * as React from 'react'import { Slot } from '@radix-ui/react-slot'import { cn } from '@/lib/utils'interface FormError<TValue extends Record<string, unknown>> { message?: string issues?: Record<keyof TValue, string>}interface Control<TValue extends Record<string, unknown>> { formValueRef: React.RefObject<TValue> formErrorRef: React.RefObject<FormError<TValue> | null> validateField: <TKey extends keyof TValue>( fieldKey?: TKey, fieldValue?: TValue[TKey], ) => Promise< | { isValid: true; data: TValue } | { isValid: false; errors: Record<keyof TValue, string> } > isPending: boolean version: number}type Validator<T> = | StandardSchemaV1<unknown, T> | (( value: T, ) => StandardSchemaV1.Result<T> | Promise<StandardSchemaV1.Result<T>>)function useForm< TValue extends Record<string, unknown>, TData, TError extends FormError<TValue>,>(params: { defaultValues: TValue validator?: Validator<TValue> onSubmit: (value: TValue) => TData | Promise<TData> onSuccess?: (data: TData) => void | Promise<void> onError?: (error: TError) => void | Promise<void>}) { const { defaultValues, validator, onSubmit, onError, onSuccess } = params const formValueRef = React.useRef<TValue>({ ...defaultValues }) const formDataRef = React.useRef<TData | null>(null) const formErrorRef = React.useRef<TError | null>(null) const [isPending, startTransition] = React.useTransition() const [version, setVersion] = React.useState(0) const validateField = React.useCallback( async <TKey extends keyof TValue>( fieldKey?: TKey, fieldValue?: TValue[TKey], ): Promise< | { isValid: true; data: TValue } | { isValid: false; errors: Record<keyof TValue, string> } > => { const valueToValidate = fieldKey ? { ...formValueRef.current, [fieldKey]: fieldValue } : formValueRef.current if (!validator) return { isValid: true, data: valueToValidate } let validationResult: StandardSchemaV1.Result<TValue> = { issues: [] } if (typeof validator === 'function') { validationResult = await validator(valueToValidate) } else { validationResult = await validator['~standard'].validate(valueToValidate) } if (validationResult.issues) { const errors = Object.fromEntries( validationResult.issues.map((issue) => [ Array.isArray(issue.path) && issue.path.length > 0 ? (issue.path[0] as keyof TValue) : ('' as keyof TValue), issue.message, ]), ) as Record<keyof TValue, string> return { isValid: false, errors } } return { isValid: true, data: validationResult.value } }, [validator], ) const handleSubmit = React.useCallback( (event: React.FormEvent) => { startTransition(async () => { event.preventDefault() event.stopPropagation() formDataRef.current = null formErrorRef.current = null const validationResult = await validateField() if (!validationResult.isValid) { formErrorRef.current = { message: 'Validation failed', issues: validationResult.errors, } as TError return } try { const result = await onSubmit(validationResult.data) formDataRef.current = result await onSuccess?.(result) } catch (error) { const message = error instanceof Error ? error.message : 'Unknown error' formErrorRef.current = { message } as TError await onError?.({ message } as TError) } }) }, [onError, onSubmit, onSuccess, validateField], ) const setValue = React.useCallback( <TKey extends keyof TValue>(key: TKey, value: TValue[TKey]) => { formValueRef.current[key] = value setVersion((v) => v + 1) }, [], ) const reset = React.useCallback(() => { formValueRef.current = { ...defaultValues } formErrorRef.current = null formDataRef.current = null setVersion((v) => v + 1) }, [defaultValues]) const control = React.useMemo( () => ({ formValueRef, formErrorRef, isPending, validateField, version }), [isPending, validateField, version], ) satisfies Control<TValue> return React.useMemo( () => ({ setValue, handleSubmit, reset, control, state: { isPending, hasError: !!formErrorRef.current, value: formValueRef.current, data: formDataRef.current, error: formErrorRef.current, }, }), [control, handleSubmit, isPending, reset, setValue], )}type ChangeEvent = | React.ChangeEvent<HTMLInputElement> | string | number | booleaninterface FormFieldContextValue< TValue extends Record<string, unknown>, TName extends keyof TValue,> { field: { name: TName value: TValue[TName] onChange: (event: ChangeEvent) => void onBlur: (event: React.FocusEvent<HTMLInputElement>) => Promise<void> | void } state: { isPending: boolean hasError: boolean error?: string } meta: { id: string formItemId: string formDescriptionId: string formMessageId: string }}const FormFieldContext = React.createContext<FormFieldContextValue< Record<string, unknown>, never> | null>(null)function useFormField< TForm extends ReturnType<typeof useForm>, TName extends keyof TForm['state']['value'],>() { const formField = React.use( FormFieldContext, ) as unknown as FormFieldContextValue<TForm['state']['value'], TName> | null if (!formField) throw new Error('useFormField must be used within a FormField') return formField}function FormField< TValue extends Record<string, unknown>, TFieldName extends keyof TValue,>({ control, name, render,}: { control: Control<TValue> name: TFieldName render: (props: FormFieldContextValue<TValue, TFieldName>) => React.ReactNode}) { const { formValueRef, formErrorRef, validateField, isPending, version } = control const [value, setValue] = React.useState(formValueRef.current[name]) const prevValueRef = React.useRef(value) const [error, setError] = React.useState( formErrorRef.current?.issues?.[name] ?? '', ) React.useEffect(() => { const newValue = formValueRef.current[name] if (newValue !== value) setValue(newValue) }, [name, value, version, formValueRef]) const parseValue = React.useCallback((target: HTMLInputElement) => { switch (target.type) { case 'number': return target.valueAsNumber as TValue[TFieldName] case 'checkbox': return target.checked as TValue[TFieldName] default: return target.value as TValue[TFieldName] } }, []) const handleChange = React.useCallback( (event: ChangeEvent) => { const newValue = typeof event === 'object' && 'target' in event ? parseValue(event.target) : (event as TValue[TFieldName]) setValue(newValue) formValueRef.current[name] = newValue }, [name, parseValue, formValueRef], ) const handleBlur = React.useCallback(async () => { if (prevValueRef.current === value) return prevValueRef.current = value const results = await validateField(name, value) if (!results.isValid && results.errors[name]) setError(results.errors[name]) else setError('') }, [name, value, validateField]) const id = React.useId() const formFieldContextValue = React.useMemo( () => ({ field: { name, value, onChange: handleChange, onBlur: handleBlur }, state: { isPending, hasError: !!error, error }, meta: { id, formItemId: `${id}-form-item`, formDescriptionId: `${id}-form-item-description`, formMessageId: `${id}-form-item-message`, }, }) satisfies FormFieldContextValue<TValue, TFieldName>, [error, handleBlur, handleChange, id, isPending, name, value], ) return ( <FormFieldContext value={formFieldContextValue as never}> {render(formFieldContextValue)} </FormFieldContext> )}function FormLabel({ className, ...props }: React.ComponentProps<'label'>) { const { state, meta } = useFormField() return ( <label data-slot='form-label' htmlFor={meta.formItemId} aria-disabled={state.isPending} aria-invalid={state.hasError} className={cn( 'text-sm leading-none font-medium', 'aria-disabled:cursor-not-allowed aria-disabled:opacity-70', 'aria-invalid:text-destructive', className, )} {...props} /> )}function FormControl({ className, ...props }: React.ComponentProps<'input'>) { const { state, meta } = useFormField() return ( <Slot data-slot='form-control' id={meta.formItemId} aria-describedby={ !state.hasError ? meta.formDescriptionId : `${meta.formDescriptionId} ${meta.formMessageId}` } aria-invalid={state.hasError} aria-disabled={state.isPending} className={cn( 'aria-disabled:cursor-not-allowed aria-disabled:opacity-70', className, )} {...props} /> )}function FormDescription({ children, className, ...props}: React.ComponentProps<'span'>) { const { meta } = useFormField() return ( <span data-slot='form-description' id={meta.formDescriptionId} className={cn('text-sm text-muted-foreground', className)} {...props} > {children} </span> )}function FormMessage({ children, className, ...props}: React.ComponentProps<'span'>) { const { state, meta } = useFormField() const body = state.hasError ? String(state.error) : children return ( <span data-slot='form-message' id={meta.formMessageId} className={cn('text-sm text-destructive', className)} {...props} > {body} </span> )}export { useForm, FormField, FormLabel, FormControl, FormDescription, FormMessage,}/** 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']}
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 {
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
export function MyForm() {
const form = useForm({
...
})
return (
<form className="grid gap-4" onSubmit={form.handleSubmit}>
<FormField
control={form.control}
name="name"
render={({ field, meta }) => (
<div id={meta.id} className="grid gap-2">
<FormLabel>Name</FormLabel>
<FormControl {...field}>
<Input placeholder="Enter your name" />
</FormControl>
<FormDescription>
Please enter your full name.
</FormDescription>
<FormMessage />
</div>
)}
/>
<Button disabled={form.state.isPending}>Submit</Button>
</form>
)
}