Form

Building forms with React Hook Form and Zod.

The Form component provides a structured way to build forms with validation, error handling, and field descriptions. It works seamlessly with React Hook Form and Zod for validation, but can also be used standalone. Built from scratch using React and native HTML form elements.

Code

TypeScript: Copy this code into components/ui/form.tsx:

tsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { Label } from "./label"

interface FormContextValue {
  errors: Record<string, string[]>
  touched: Record<string, boolean>
}

const FormContext = React.createContext<FormContextValue | undefined>(undefined)

const useFormContext = () => {
  const context = React.useContext(FormContext)
  return context
}

interface FormProps {
  onSubmit?: (e: React.FormEvent<HTMLFormElement>) => void
  children: React.ReactNode
  className?: string
  errors?: Record<string, string[]>
  touched?: Record<string, boolean>
}

const Form = React.forwardRef<HTMLFormElement, FormProps>(
  ({ onSubmit, children, className, errors = {}, touched = {}, ...props }, ref) => {
    const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
      e.preventDefault()
      onSubmit?.(e)
    }

    return (
      <FormContext.Provider value={{ errors, touched }}>
        <form
          ref={ref}
          onSubmit={handleSubmit}
          className={cn("space-y-4", className)}
          {...props}
        >
          {children}
        </form>
      </FormContext.Provider>
    )
  }
)
Form.displayName = "Form"

interface FormFieldProps {
  name: string
  children: (props: {
    field: {
      name: string
      value: any
      onChange: (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => void
      onBlur: (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => void
    }
    error?: string
  }) => React.ReactNode
  value?: any
  onChange?: (value: any) => void
  onBlur?: () => void
}

const FormField = ({ name, children, value, onChange, onBlur }: FormFieldProps) => {
  const context = useFormContext()
  const [localValue, setLocalValue] = React.useState(value ?? "")
  const [isTouched, setIsTouched] = React.useState(false)

  const fieldValue = value !== undefined ? value : localValue
  const error = context?.errors[name]?.[0]
  const touched = context?.touched[name] ?? isTouched

  const handleChange = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    const newValue = e.target.value
    if (value === undefined) {
      setLocalValue(newValue)
    }
    onChange?.(newValue)
  }

  const handleBlur = (e: React.FocusEvent<HTMLInputElement | HTMLTextAreaElement>) => {
    setIsTouched(true)
    onBlur?.()
  }

  return (
    <>
      {children({
        field: {
          name,
          value: fieldValue,
          onChange: handleChange,
          onBlur: handleBlur,
        },
        error: touched ? error : undefined,
      })}
    </>
  )
}
FormField.displayName = "FormField"

interface FormItemProps extends React.HTMLAttributes<HTMLDivElement> {}

const FormItem = React.forwardRef<HTMLDivElement, FormItemProps>(
  ({ className, ...props }, ref) => {
    return (
      <div
        ref={ref}
        className={cn("space-y-2", className)}
        {...props}
      />
    )
  }
)
FormItem.displayName = "FormItem"

interface FormLabelProps extends React.LabelHTMLAttributes<HTMLLabelElement> {}

const FormLabel = React.forwardRef<HTMLLabelElement, FormLabelProps>(
  ({ className, ...props }, ref) => {
    return (
      <Label
        ref={ref}
        className={className}
        {...props}
      />
    )
  }
)
FormLabel.displayName = "FormLabel"

interface FormDescriptionProps extends React.HTMLAttributes<HTMLParagraphElement> {}

const FormDescription = React.forwardRef<HTMLParagraphElement, FormDescriptionProps>(
  ({ className, ...props }, ref) => (
    <p
      ref={ref}
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
)
FormDescription.displayName = "FormDescription"

interface FormMessageProps extends React.HTMLAttributes<HTMLParagraphElement> {
  children?: React.ReactNode
}

const FormMessage = React.forwardRef<HTMLParagraphElement, FormMessageProps>(
  ({ className, children, ...props }, ref) => {
    if (!children) return null

    return (
      <p
        ref={ref}
        className={cn("text-sm font-bold text-destructive", className)}
        {...props}
      >
        {children}
      </p>
    )
  }
)
FormMessage.displayName = "FormMessage"

export {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormDescription,
  FormMessage,
}

JavaScript: Copy this code into components/ui/form.jsx:

jsx
"use client"

import * as React from "react"
import { cn } from "@/lib/utils"
import { Label } from "./label"

const FormContext = React.createContext(undefined)

const useFormContext = () => {
  const context = React.useContext(FormContext)
  return context
}

const Form = React.forwardRef(
  ({ onSubmit, children, className, errors = {}, touched = {}, ...props }, ref) => {
    const handleSubmit = (e) => {
      e.preventDefault()
      onSubmit?.(e)
    }

    return (
      <FormContext.Provider value={{ errors, touched }}>
        <form
          ref={ref}
          onSubmit={handleSubmit}
          className={cn("space-y-4", className)}
          {...props}
        >
          {children}
        </form>
      </FormContext.Provider>
    )
  }
)
Form.displayName = "Form"

const FormField = ({ name, children, value, onChange, onBlur }) => {
  const context = useFormContext()
  const [localValue, setLocalValue] = React.useState(value ?? "")
  const [isTouched, setIsTouched] = React.useState(false)

  const fieldValue = value !== undefined ? value : localValue
  const error = context?.errors[name]?.[0]
  const touched = context?.touched[name] ?? isTouched

  const handleChange = (e) => {
    const newValue = e.target.value
    if (value === undefined) {
      setLocalValue(newValue)
    }
    onChange?.(newValue)
  }

  const handleBlur = (e) => {
    setIsTouched(true)
    onBlur?.()
  }

  return (
    <>
      {children({
        field: {
          name,
          value: fieldValue,
          onChange: handleChange,
          onBlur: handleBlur,
        },
        error: touched ? error : undefined,
      })}
    </>
  )
}
FormField.displayName = "FormField"

const FormItem = React.forwardRef(
  ({ className, ...props }, ref) => {
    return (
      <div
        ref={ref}
        className={cn("space-y-2", className)}
        {...props}
      />
    )
  }
)
FormItem.displayName = "FormItem"

const FormLabel = React.forwardRef(
  ({ className, ...props }, ref) => {
    return (
      <Label
        ref={ref}
        className={className}
        {...props}
      />
    )
  }
)
FormLabel.displayName = "FormLabel"

const FormDescription = React.forwardRef(
  ({ className, ...props }, ref) => (
    <p
      ref={ref}
      className={cn("text-sm text-muted-foreground", className)}
      {...props}
    />
  )
)
FormDescription.displayName = "FormDescription"

const FormMessage = React.forwardRef(
  ({ className, children, ...props }, ref) => {
    if (!children) return null

    return (
      <p
        ref={ref}
        className={cn("text-sm font-bold text-destructive", className)}
        {...props}
      >
        {children}
      </p>
    )
  }
)
FormMessage.displayName = "FormMessage"

export {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormDescription,
  FormMessage,
}

Usage

TypeScript:

tsx
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormDescription,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { useState } from "react"

function MyForm() {
  const [username, setUsername] = useState("")
  const [errors, setErrors] = useState<Record<string, string[]>>({})

  const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
    e.preventDefault()
    const newErrors: Record<string, string[]> = {}
    
    if (!username || username.length < 3) {
      newErrors.username = ["Username must be at least 3 characters"]
    }

    setErrors(newErrors)
    if (Object.keys(newErrors).length === 0) {
    }
  }

  return (
    <Form onSubmit={handleSubmit} errors={errors}>
      <FormField
        name="username"
        value={username}
        onChange={(value) => setUsername(value as string)}
      >
        {({ field, error }) => (
          <FormItem>
            <FormLabel>Username</FormLabel>
            <Input {...field} />
            <FormDescription>
              This is your public display name.
            </FormDescription>
            <FormMessage>{error}</FormMessage>
          </FormItem>
        )}
      </FormField>
      <Button type="submit">Submit</Button>
    </Form>
  )
}

JavaScript:

jsx
import {
  Form,
  FormField,
  FormItem,
  FormLabel,
  FormDescription,
  FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import { useState } from "react"

function MyForm() {
  const [username, setUsername] = useState("")
  const [errors, setErrors] = useState({})

  const handleSubmit = (e) => {
    e.preventDefault()
    const newErrors = {}
    
    if (!username || username.length < 3) {
      newErrors.username = ["Username must be at least 3 characters"]
    }

    setErrors(newErrors)
    if (Object.keys(newErrors).length === 0) {
    }
  }

  return (
    <Form onSubmit={handleSubmit} errors={errors}>
      <FormField
        name="username"
        value={username}
        onChange={(value) => setUsername(value as string)}
      >
        {({ field, error }) => (
          <FormItem>
            <FormLabel>Username</FormLabel>
            <Input {...field} />
            <FormDescription>
              This is your public display name.
            </FormDescription>
            <FormMessage>{error}</FormMessage>
          </FormItem>
        )}
      </FormField>
      <Button type="submit">Submit</Button>
    </Form>
  )
}

Make sure you also have the lib/utils.ts file with the cn helper function, and the required UI components (input, button,label).

Examples

Default

This is your public display name.