Tiesen Logo
Hooks

useForm

A custom React hook for form state management and validation

GitHubComponent 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.json
npx shadcn add https://yuki-ui.vercel.app/r/use-form.json
pnpm dlx shadcn add https://yuki-ui.vercel.app/r/use-form.json
bunx --bun shadcn add https://yuki-ui.vercel.app/r/use-form.json

Manual

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>
  )
}