Form Component

API reference for the <Form /> component — props, the onSubmit caveat, and the FormRef imperative handle.

View as Markdown

<Form /> Component

The main entry point of @saastro/forms. It takes a FormConfig, builds the Zod schema, wires up React Hook Form, renders the current step’s fields through your injected UI components, and runs the full submit pipeline.

import { Form } from '@saastro/forms';

<Form
  config={config}
  components={import.meta.glob('@/components/ui/*.tsx', { eager: true })}
/>;

Props

<Form> accepts exactly five props (FormProps):

PropTypeDefaultDescription
configFormConfig— requiredThe full form configuration: fields, steps, layout, buttons, submit actions, plugins. See Types Reference
componentsComponentOverrides | GlobModulesundefinedUI components for the form — a component object or a raw import.meta.glob(..., { eager: true }) result (auto-detected)
onSubmit(values: Record<string, unknown>) => void | Promise<void>undefinedOnly fires on native form submission (e.g. pressing Enter) — not when the rendered submit button is clicked. See below
onError(error: Error) => voidundefinedNative-path only, like onSubmit — and submit-pipeline failures never reach it (they’re caught internally). See below
classNamestringundefinedCSS class for the <form> element. When omitted, the form falls back to w-full

config

The only required prop. Build it with FormBuilder or write the object by hand — the shape is documented in the Types Reference. Note that the identifier property is formId (not id), steps is a Record<string, Step> keyed by step id, and column count lives under layout.

Most behavior callbacks live on the config, not on the component: config.onSuccess, config.onError, config.onStepChange, config.successMessage, config.redirect, and so on.

components

Accepts either form:

// 1. Glob modules (Vite) — auto-discovers every component in the folder.
//    `eager: true` is required.
<Form config={config} components={import.meta.glob('@/components/ui/*.tsx', { eager: true })} />

// 2. Explicit component object — provide only what your form needs
<Form config={config} components={{ Input, Button, Field, FieldLabel, FieldDescription, FieldError, FormField, FormControl }} />

If you render <Form> with no components prop and no surrounding provider, the form is not rendered — you get a “Missing UI Components” error panel with setup instructions instead. A partial registry renders the form, and any individually missing components show a per-field fallback with install instructions.

Provider precedence: inside a legacy ComponentProvider, the provider’s registry wins and the components prop is effectively ignored. Use one mechanism or the other. See Component System for all three injection modes.

onSubmit and onError — read this before using them

The submit button that <Form> renders is deliberately type="button": clicking it goes through the library’s internal pipeline (confirmation UX → per-step validation → submit actions → config.onSuccess), bypassing native form submission entirely. The onSubmit prop is attached to the native <form onSubmit> handler, so:

  • onSubmit fires only when the form is submitted natively — for example, pressing Enter in a field — and only on the last step, after the internal submit pipeline finishes. Note it fires even if the pipeline failed: pipeline errors are caught internally (they set the error UI and call config.onError), so the native handler never sees them.
  • onError fires only on that same native path, and — because pipeline errors are swallowed internally — in practice only when your own onSubmit callback throws.
  • Clicking the rendered submit button never calls either prop.

For callbacks that fire on every successful or failed submission, use the config instead:

const config = FormBuilder.create('contact')
  // ...fields and steps...
  .onSuccess((values) => console.log('submitted', values))
  .onError((error, values) => console.error(error))
  .build();

See Submitting Forms for the full pipeline.

className

Applied to the underlying <form> element. Defaults to w-full when omitted. For styling fields and the grid, see Styling and Layout System.


Imperative Handle: FormRef

<Form> is a forwardRef component. Attach a ref to control the form from outside — prefill values, reset it, read the current values, or trigger validation:

import type { UseFormReturn } from 'react-hook-form';

interface FormRef {
  setValue: UseFormReturn['setValue']; // setValue(name, value, options?)
  reset: UseFormReturn['reset']; // reset(values?, options?) — raw RHF reset
  getValues: UseFormReturn['getValues']; // getValues() / getValues(name)
  trigger: UseFormReturn['trigger']; // trigger(name?) => Promise<boolean>
  resetForm: (options?: ResetFormOptions) => void; // granular reset (see below)
  getDirtyValues: () => Record<string, unknown>; // user-edited values only
  getDirtyPaths: () => string[]; // dirty leaf dot-paths
  isDirty: () => boolean;
}
MethodSignatureDescription
setValue(name, value, options?) => voidSet a field’s value programmatically
reset(values?, options?) => voidRaw RHF reset — note it cannot dismiss the success screen or clear the submit error
getValues(name?) => valuesRead current values — all of them, or a single field by name
trigger(name?) => Promise<boolean>Run validation for one field, several, or the whole form; resolves true when valid
resetForm(options?) => voidGranular reset: RHF reset plus clearing the package’s submit state (success/error)
getDirtyValues() => Record<string, unknown>Values filtered to user-edited top-level fields (machine writes excluded)
getDirtyPaths() => string[]Dirty leaf dot-paths, e.g. ['email', 'contacts.0.name']
isDirty() => booleanWhether any field is user-dirty

Granular reset: resetForm(options?)

resetForm composes RHF’s reset with the package’s bespoke submit state. Each option maps to RHF’s KeepStateOptions — plus one package-specific flag:

OptionMaps toEffect
valuesreset(values)New values (and new defaults, unless keepDefaultValues)
keepValuesRHF keepValuesKeep current values, reset the rest of the state
keepErrorsRHF keepErrorsKeep field validation errors
keepTouchedRHF keepTouchedKeep touched flags
keepDirtyRHF keepDirtyKeep dirty flags
keepDefaultValuesRHF keepDefaultValuesNew values don’t become the new defaults
keepIsSubmittedRHF keepIsSubmittedKeep RHF’s isSubmitted flag
keepSubmitCountRHF keepSubmitCountKeep RHF’s submitCount
keepSubmitState(package)Don’t clear the success screen / submit error / actions result

Common recipes: resetForm() — full reset back to a pristine form view after a successful submit; resetForm({ keepValues: true }) — clear errors/flags but keep what the user typed; resetForm({ values }) — programmatic prefill.

Example

import { useRef } from 'react';
import { Form, FormBuilder, type FormRef } from '@saastro/forms';

const config = FormBuilder.create('newsletter')
  .addField('email', (f) => f.type('email').label('Email').required().email())
  .addStep('main', ['email'])
  .build();

export function Newsletter() {
  const formRef = useRef<FormRef>(null);

  return (
    <div>
      <button type="button" onClick={() => formRef.current?.setValue('email', 'jane@example.com')}>
        Prefill
      </button>
      <button type="button" onClick={() => formRef.current?.reset()}>
        Clear
      </button>
      <button type="button" onClick={() => console.log(formRef.current?.getValues())}>
        Log values
      </button>
      <button
        type="button"
        onClick={async () => {
          const valid = await formRef.current?.trigger();
          console.log(valid ? 'Valid' : 'Has errors');
        }}
      >
        Validate
      </button>

      <Form
        ref={formRef}
        config={config}
        components={import.meta.glob('@/components/ui/*.tsx', { eager: true })}
      />
    </div>
  );
}

trigger() validates and shows error messages, but it does not submit. There is no imperative submit method — submission goes through the rendered submit button. (To fire individual submit actions imperatively — outside the full pipeline — see manual submit action triggers.)


Success and error UI

After a successful submission, the entire form is replaced by a success panel. The default message is '✅ Thank you!' — one of the library’s built-in English defaults (along with button labels like Submit). Override it per form with config.successMessage, per locale via Internationalization, or globally with setDefaultMessages. When a submission fails, an error panel renders below the form with config.errorMessage (or its default).

String success/error messages are rendered as HTML, so successMessage: 'Done! <a href="/next">Continue</a>' works. The HTML is sanitized (same for HTML field labels): <script>/<iframe>/<svg>-class elements, on* handlers, and javascript: URLs are stripped — formatting tags and safe links pass through. The html field type is the unsanitized escape hatch for trusted markup.

Hardened apps can replace the built-in sanitizer globally:

import DOMPurify from 'dompurify';
import { setHtmlSanitizer } from '@saastro/forms';

setHtmlSanitizer((html) => DOMPurify.sanitize(html));

  • Quickstart — Build and render your first form
  • Component System — All three component-injection modes and auto-discovery details
  • Submitting Forms — What happens when the user clicks submit
  • Types ReferenceFormConfig and every other exported type
  • HubForm — A wrapper around <Form> that fetches its config from the hosted submit service