forms

Build forms with validation, and show server errors next to the right field.

FormsClientBuilt on react-hook-form

Forms are where most apps get fiddly: checking input before it's sent, disabling the button while saving, and showing the server's "this email is already taken" under the email field instead of in a generic alert. kitr/forms handles all three with one schema and one component.

It is React Hook Form, re-exported, plus useSchemaForm, a <Form> component and a few accessible field helpers.

Install

kitr add forms

First time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.

This installs @kitr/forms, react-hook-form, @hookform/resolvers and zod. There is no config file. kitr ui add form also copies a styled version of the field components into app/components/ui/form.tsx.

A complete form

// app/features/projects/new-project-form.tsx
import { z } from 'zod'
import { FieldInput, Form, FormField, SubmitButton, useRootError, useSchemaForm } from 'kitr/forms'
import { useKitrMutation } from 'kitr/query'
import { createProject } from './queries'

const schema = z.object({
  name: z.string().min(2, 'Give it at least two characters'),
  slug: z.string().regex(/^[a-z0-9-]+$/, 'Lowercase letters, numbers and dashes'),
})

export function NewProjectForm() {
  const form = useSchemaForm(schema, { defaultValues: { name: '', slug: '' } })
  const create = useKitrMutation(createProject)

  return (
    <Form form={form} onSubmit={(values) => create.mutateAsync(values)} resetOnSuccess>
      <FormField name="name" label="Name">
        <FieldInput />
      </FormField>
      <FormField name="slug" label="URL" description="Used in links to this project.">
        <FieldInput autoComplete="off" />
      </FormField>
      <RootError />
      <SubmitButton pendingText="Saving…">Create project</SubmitButton>
    </Form>
  )
}

function RootError() {
  const error = useRootError()
  return error ? <p role="alert">{error}</p> : null
}

kitr make:form new-project writes a starting point like this.

What you get:

  • Validation before sending. The schema runs when a field loses focus and again on submit. Messages appear under the field.
  • A pending state. <SubmitButton> is disabled and shows pendingText while onSubmit runs.
  • Server errors on the right field. If onSubmit throws a KitrError with fieldErrors, each message lands on its field. Anything else shows in useRootError().
  • Accessible markup. Each field gets an id, a <label for>, aria-describedby for its description and error, and aria-invalid when it fails.

Any schema library

useSchemaForm accepts any Standard Schema: zod, valibot or arktype. The schema's output type becomes the type of values in onSubmit, so a z.coerce.number() field arrives as a number.

Server errors

The server side doesn't need to know about forms. Any kitr source already returns errors in the right shape:

  • API routes validate the body and respond 422 with fieldErrors.
  • Server actions do the same when their schema fails.
  • HTTP maps common backend formats, including the Adonis and VineJS errors array.

You can also throw one yourself:

throw KitrError.validation({ email: ['Already taken'] })

Nested fields use dot paths (address.city). A field name the form doesn't have, or _root, goes to the root error.

Custom inputs

<FieldInput> is a plain <input>. For anything else, spread useFieldProps() onto your own element inside <FormField>:

function RoleSelect() {
  const props = useFieldProps()
  return (
    <select {...props}>
      <option value="member">Member</option>
      <option value="admin">Admin</option>
    </select>
  )
}

For components that don't take a ref, such as a date picker, use React Hook Form's Controller as usual. It's exported from kitr/forms.

Next steps