Logo

Build your own form

Learn how to build your own form with React and usng Standard Schema to validate the form.

  • React
  • Form
  • Standard Schema

Introduction

In this tutorial, we will learn how to build a form with React and validate it using Standard Schema.

Installation

First, let's create a new Next.js project.

npx shadcn@latest init
pnpm dlx shadcn@latest init
npx shadcn@latest init
bunx --bun shadcn@latest init

Second, install the required packages.

npm install @standard-schema/spec @radix-ui/react-slot
pnpm add @standard-schema/spec @radix-ui/react-slot
yarn add @standard-schema/spec @radix-ui/react-slot
bun add @standard-schema/spec @radix-ui/react-slot
npm install arktype # or zod, valibot
pnpm add arktype # or zod, valibot
yarn add arktype # or zod, valibot
bun add arktype # or zod, valibot

Create a form

Create a new file components/ui/form.tsx and add the following code.

  • Create the useForm hook to manage the form state.
components/ui/form.tsx
const useForm = <TSchema extends StandardSchemaV1, TData = unknown>({
  schema,
  defaultValues,
  submitFn,
  onSuccess,
  onError,
}: {
  schema: TSchema
  defaultValues: StandardSchemaV1.InferInput<TSchema>
  submitFn: (
    values: StandardSchemaV1.InferInput<TSchema>,
  ) => Promise<TData> | TData
  onSuccess?: (data: TData) => Promise<void> | void
  onError?: (error: string) => Promise<void> | void
}) => {
  const [values, setValues] = React.useState(defaultValues)
  const [isPending, startTransition] = React.useTransition()
  const [errors, setErrors] = React.useState<{
    message?: string
    fieldErrors?: Record<keyof StandardSchemaV1.InferInput<TSchema>, string>
  }>({})

  const handleSubmit = React.useCallback(
    (e: React.FormEvent<HTMLFormElement>) => {
      startTransition(async () => {
        e.preventDefault()
        e.stopPropagation()

        const parsed = await schema['~standard'].validate(values)

        if (parsed.issues) {
          setErrors({
            message: 'Validation error',
            fieldErrors: parsed.issues.reduce<Record<string, string>>(
              (acc, issue) => ({
                ...acc,
                [issue.path as never]: issue.message,
              }),
              {},
            ) as Record<keyof StandardSchemaV1.InferInput<TSchema>, string>,
          })
          if (onError) void onError('Validation error')
          return
        }

        try {
          const data = await submitFn(parsed.value)
          if (onSuccess) void onSuccess(data)
          setErrors({})
        } catch (error) {
          if (error instanceof Error) {
            setErrors({ message: error.message })
            if (onError) void onError(error.message)
          } else {
            setErrors({ message: 'Unknown error' })
            if (onError) void onError('Unknown error')
          }
        }
      })
    },
    [onError, onSuccess, schema, submitFn, values],
  )

  const handleChange = (key: string, value: unknown) => {
    setValues((prev) => ({
      ...(prev as Record<string, unknown>),
      [key]: value,
    }))
  }

  const handleBlur = React.useCallback(
    async (event: React.FocusEvent<HTMLInputElement>) => {
      const parsed = await schema['~standard'].validate({
        ...(values as Record<string, unknown>),
        [event.target.name]: event.target.value,
      })

      if (parsed.issues) {
        parsed.issues.forEach((issue) => {
          setErrors((prev) => ({
            ...prev,
            fieldErrors: {
              ...(prev.fieldErrors as unknown as Record<
                keyof StandardSchemaV1.InferInput<TSchema>,
                string
              >),
              [issue.path as never]: issue.message,
            },
          }))
        })
      } else {
        setErrors((prev) => ({
          ...prev,
          fieldErrors: {
            ...(prev.fieldErrors as unknown as Record<
              keyof StandardSchemaV1.InferInput<TSchema>,
              string
            >),
            [event.target.name]: undefined,
          },
        }))
      }
    },
    [schema, values],
  )

  const reset = React.useCallback(() => {
    setValues(defaultValues)
    setErrors({})
  }, [defaultValues])

  return {
    handleSubmit,
    handleChange,
    handleBlur,
    reset,
    isPending,
    values,
    errors,
  }
}

In this hook, we use the useTransition hook to handle the form submission. We validate the form data using the Standard Schema and show the error messages if there are any.

  • Create the Form component to render the form.
components/ui/form.tsx
type FormContextValue<T extends StandardSchemaV1> = ReturnType<
  typeof useForm<T>
>
const FormContext = React.createContext<FormContextValue<StandardSchemaV1>>(
  {} as FormContextValue<StandardSchemaV1>,
)

function Form<T extends StandardSchemaV1>({
  className,
  form,
  ...props
}: React.ComponentProps<'form'> & { form: FormContextValue<T> }) {
  return (
    <FormContext.Provider value={form}>
      <form
        data-slot="form"
        className={cn('grid gap-4', className)}
        onSubmit={form.handleSubmit}
        {...props}
      />
    </FormContext.Provider>
  )
}

The Form component uses the FormContext to provide the form state to its children. It also handles the form submission and validation.

  • Create the FormField component to render the form fields.
components/ui/form.tsx
interface FormFieldContextValue {
  name: string
  value?: string
  error?: string
  isPending?: boolean
  formItemId?: string
  formDescriptionId?: string
  formMessageId?: string
}
const FormFieldContext = React.createContext<FormFieldContextValue>(
  {} as FormFieldContextValue,
)

function FormField({
  name,
  render,
}: {
  name: string
  render: (props: {
    name: string
    value: string
    onChange: (e: React.ChangeEvent<HTMLInputElement>) => void
    onBlur: (e: React.FocusEvent<HTMLInputElement>) => Promise<void>
  }) => React.ReactNode
}) {
  const form = React.use(FormContext)

  return (
    <FormFieldContext.Provider value={{ name }}>
      {render({
        name,
        value: (form.values as never)[name],
        onChange: React.useCallback(
          (
            event:
              | React.ChangeEvent<HTMLInputElement>
              | string
              | number
              | boolean,
          ) => {
            if (event && typeof event === 'object') {
              let newValue: unknown = event.target.value
              if (event.target.type === 'number')
                newValue = event.target.valueAsNumber
              else if (event.target.type === 'checkbox')
                newValue = event.target.checked
              else if (event.target.type === 'date')
                newValue = event.target.valueAsDate

              form.handleChange(name, newValue)
            } else {
              form.handleChange(name, event)
            }
          },
          [form, name],
        ),
        onBlur: form.handleBlur,
      })}
    </FormFieldContext.Provider>
  )
}

The FormField component uses the FormFieldContext to provide the form field state to its children. It also handles the form field changes and validation.

  • Create the FormItem component to render the form item.
components/ui/form.tsx
interface FormItemContextValue {
  id: string
}
const FormItemContext = React.createContext<FormItemContextValue>(
  {} as FormItemContextValue,
)

function FormItem({ className, ...props }: React.ComponentProps<'fieldset'>) {
  const { isPending } = React.use(FormContext)
  const id = React.useId()

  return (
    <FormItemContext.Provider value={{ id }}>
      <fieldset
        data-slot="form-item"
        data-disabled={isPending}
        className={cn('group grid gap-2', className)}
        disabled={isPending}
        {...props}
      />
    </FormItemContext.Provider>
  )
}

The FormItem component uses the FormItemContext to provide the form item state to its children. It also handles the form item changes and validation.

  • Create the useFormField hook to manage the form field state.
components/ui/form.tsx
const useFormField = () => {
  const formContext = React.use(FormContext)
  const fieldContext = React.use(FormFieldContext)
  const itemContext = React.use(FormItemContext)

  return {
    id: itemContext.id,
    name: fieldContext.name,
    value: (formContext.values as never)[fieldContext.name],
    error: formContext.errors.fieldErrors?.[fieldContext.name as never],
    isPending: formContext.isPending,
    formItemId: `${itemContext.id}-form-item`,
    formDescriptionId: `${itemContext.id}-form-item-description`,
    formMessageId: `${itemContext.id}-form-item-message`,
  }
}

The useFormField hook provides the form field state to its children. It also handles the form field changes and validation.

  • Finally, create the rest of the form components to render the form fields.
components/ui/form.tsx
function FormLabel({ className, ...props }: React.ComponentProps<'label'>) {
  const { formItemId, error } = useFormField()

  return (
    <label
      data-slot="form-label"
      data-error={!!error}
      htmlFor={formItemId}
      className={cn(
        'flex items-center gap-2 text-sm leading-none font-medium select-none group-data-[disabled=true]:pointer-events-none group-data-[disabled=true]:opacity-50 peer-disabled:cursor-not-allowed peer-disabled:opacity-50',
        'data-[error=true]:text-destructive',
        className,
      )}
      {...props}
    />
  )
}

function FormControl({ ...props }: React.ComponentProps<typeof Slot>) {
  const { error, isPending, formItemId, formDescriptionId, formMessageId } =
    useFormField()

  return (
    <Slot
      data-slot="form-control"
      id={formItemId}
      aria-describedby={
        // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
        !error ? formDescriptionId : `${formDescriptionId} ${formMessageId}`
      }
      aria-invalid={!!error}
      aria-disabled={isPending}
      {...props}
    />
  )
}

function FormDescription({ className, ...props }: React.ComponentProps<'p'>) {
  const { formDescriptionId } = useFormField()

  return (
    <p
      data-slot="form-description"
      id={formDescriptionId}
      className={cn('text-muted-foreground text-sm', className)}
      {...props}
    />
  )
}

function FormMessage({ className, ...props }: React.ComponentProps<'p'>) {
  const { formMessageId, error } = useFormField()

  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
  const body = error ? String(error) : props.children

  if (!body) return null

  return (
    <p
      data-slot="form-message"
      id={formMessageId}
      className={cn('text-destructive text-sm', className)}
      {...props}
    >
      {body}
    </p>
  )
}

The FormLabel, FormControl, FormDescription, and FormMessage components render the form fields and handle the form field changes and validation.

Usage

Now, you can use the form components to build your own form.

  1. Create the form schema. In this example, we use the arktype package to create the form schema.
pages/index.tsx
import { type } from 'arktype'

const loginSchema = type({
  email: type('string.email'),
  password: type(
    /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9]).{8,}$/,
  ).describe('valid password'),
})
  1. Create the form variables.
pages/index.tsx
const form = useForm({
  schema: loginSchema,
  defaultValues: { email: '', password: '' },
  submitFn: async (values) => {
    await new Promise((resolve) => setTimeout(resolve, 1000))
    return values
  },
  onError: (e) => {
    toast.error(e)
  },
  onSuccess: (data) => {
    toast.success('Logged in successfully', {
      description: <pre>{JSON.stringify(data, null, 2)}</pre>,
    })
  },
})
  1. Create the form fields.
pages/index.tsx
'use client'

const LoginForm: React.FC = () => {
  const form = useForm({ ... })

  return (
    <Form form={form}>
      <FormField
        name="email"
        render={(props) => (
          <FormItem>
            <FormLabel>Email</FormLabel>
            <FormControl {...props}>
              <Input type="email" placeholder="yuki@gmail.com" />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />

      <FormField
        name="password"
        render={(props) => (
          <FormItem>
            <div className="flex items-center justify-between">
              <FormLabel>Password</FormLabel>

              <a
                href="#"
                className="text-sm underline-offset-4 hover:underline"
              >
                Forgot your password?
              </a>
            </div>
            <FormControl {...props}>
              <Input type="password" />
            </FormControl>
            <FormMessage />
          </FormItem>
        )}
      />

      <Button disabled={form.isPending} type="submit">
        Login
      </Button>
    </Form>
  )
}

Conclusion

In this tutorial, we learned how to build a form with React and validate it using Standard Schema. You can use the form components to build your own form and handle the form submission and validation.

I hope you enjoyed this tutorial. If you have any questions or feedback, feel free to leave a comment below.

Resources

npx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
pnpm dlx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
npx shadcn@latest add https://yuki-ui.vercel.app/r/form.json
bunx --bun add https://yuki-ui.vercel.app/r/form.json