StackForm
Getting Started

Using with React Hook Form

Connect StackForm to React Hook Form via RHFFormProvider

Install

npm install @stackform/core @stackform/ui @stackform/rhf react-hook-form

Quick start

A complete contact form with two fields, error display, and submission:

import { useForm } from 'react-hook-form'
import { StackFormProvider, TextField } from '@stackform/ui'
import { RHFFormProvider } from '@stackform/rhf'

interface FormValues {
  name: string
  email: string
}

export function ContactForm() {
  const form = useForm<FormValues>({
    defaultValues: { name: '', email: '' },
  })

  return (
    <StackFormProvider>
      <RHFFormProvider form={form}>
        <form onSubmit={form.handleSubmit(console.log)} className="space-y-4">
          <TextField name="name" label="Name" placeholder="Your name" />
          <TextField
            name="email"
            label="Email"
            type="email"
            placeholder="you@example.com"
            hint="We'll never share your email."
          />
          <button type="submit">Submit</button>
        </form>
      </RHFFormProvider>
    </StackFormProvider>
  )
}

Provider setup

Two providers are required, in this order:

ProviderPackagePurpose
StackFormProvider@stackform/uiProvides default slot implementations and shared classNames
RHFFormProvider@stackform/rhfBridges React Hook Form's form instance into StackForm context

StackFormProvider must be the outer wrapper. Field components call useField() internally — you never wire them to the form instance manually.


Error display

Errors surface automatically. When React Hook Form sets a field error — via setError, a resolver, or built-in validation — the field reads it from context and renders the message below the input. No prop wiring required.

const form = useForm<{ email: string }>({
  defaultValues: { email: '' },
})

// Trigger an error manually
form.setError('email', { message: 'Invalid email address' })

// TextField renders it automatically
<TextField name="email" label="Email" />

To trigger errors on submit, use a resolver (e.g. @hookform/resolvers/zod) or React Hook Form's built-in validation rules. StackForm displays whatever the form library sets.


Form submission

Call form.handleSubmit on the <form> element's onSubmit. React Hook Form validates all fields before calling your handler — if validation fails, errors appear in the fields automatically.

const form = useForm<FormValues>({
  defaultValues: { name: '', email: '' },
})

async function onSubmit(values: FormValues) {
  await saveToApi(values)
}

<form onSubmit={form.handleSubmit(onSubmit)}>
  {/* fields */}
  <button type="submit" disabled={form.formState.isSubmitting}>
    {form.formState.isSubmitting ? 'Saving…' : 'Save'}
  </button>
</form>

useRHFForm factory

useRHFForm is an optional factory that returns both the form handle and a pre-wired FormProvider component, reducing import surface and improving TypeScript inference:

import { useRHFForm } from '@stackform/rhf'
import { StackFormProvider, TextField } from '@stackform/ui'

interface FormValues {
  name: string
  email: string
}

export function ContactForm() {
  const { form, FormProvider } = useRHFForm<FormValues>({
    defaultValues: { name: '', email: '' },
  })

  return (
    <StackFormProvider>
      <FormProvider>
        <form onSubmit={form.handleSubmit(console.log)} className="space-y-4">
          <TextField name="name" label="Name" />
          <TextField name="email" label="Email" type="email" />
          <button type="submit">Submit</button>
        </form>
      </FormProvider>
    </StackFormProvider>
  )
}

FormProvider is memoised inside the hook so it does not re-create on every render.


Slot override example

Replace any slot with a custom component. Slot components receive only display-level props and are unaware of the form library.

import type { LabelSlotProps } from '@stackform/ui'

function BoldLabel({ htmlFor, children, required }: LabelSlotProps) {
  return (
    <label htmlFor={htmlFor} className="font-bold uppercase tracking-wide text-xs">
      {children}
      {required ? <span aria-hidden="true"> *</span> : null}
    </label>
  )
}

<TextField
  name="email"
  label="Email"
  slots={{ Label: BoldLabel }}
/>