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 seven props (FormProps):

Prop Type Default Description
config FormConfig — required The full form configuration: fields, steps, layout, buttons, submit actions, plugins. See Types Reference
components ComponentOverrides | GlobModules undefined UI 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> undefined Only fires on native form submission (e.g. pressing Enter) — not when the rendered submit button is clicked. See below
onError (error: Error) => void undefined Native-path only, like onSubmit — and submit-pipeline failures never reach it (they’re caught internally). See below
className string undefined CSS class for the <form> element. When omitted, the form falls back to w-full
action string undefined Progressive enhancement: action of the <form>, so the form still submits when JS never runs. Inert while JS works. See below
method 'get' | 'post' undefined Goes with action. Omitted from the markup when not passed

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.

action and method

A form rendered on the server — see initialSchema — is visible without JavaScript, but it does not submit: with no action, a native submit reloads the same URL and the values are gone. On a paid landing page that is a lost lead. These two props close that gap; the fields already carry their name, so the browser can serialize them on its own.

<Form
  config={config}
  components={components}
  action="https://submit.saastro.io/v1/acme/contact/submit"
  method="post"
/>

Two things to know before you reach for them:

  • They are inert while JavaScript works. handleSubmit from react-hook-form calls preventDefault(), so a working page still submits through the usual fetch path — the one that speaks application/json and knows about attachments and captcha. Omit them and the emitted <form> is byte-for-byte what it was before.
  • The target must accept application/x-www-form-urlencoded, which is the only thing a native <form> knows how to send. The Hub ingestion endpoint accepts application/json only as of today, so pointing action at it right now would hand the visitor an error instead of nothing. That is why nothing is wired by default.

<Form> itself does not police where you point it — it takes a plain FormConfig and also serves forms that never came from the Hub. The gate lives in <HubForm>, which owns the Hub schema: it drops action/method unless that schema carries meta.nativeSubmit, so a Hub form cannot render a submit the ingestion worker is going to reject.


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;
}
Method Signature Description
setValue (name, value, options?) => void Set a field’s value programmatically
reset (values?, options?) => void Raw RHF reset — note it cannot dismiss the success screen or clear the submit error
getValues (name?) => values Read 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?) => void Granular 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 () => boolean Whether 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:

Option Maps to Effect
values reset(values) New values (and new defaults, unless keepDefaultValues)
keepValues RHF keepValues Keep current values, reset the rest of the state
keepErrors RHF keepErrors Keep field validation errors
keepTouched RHF keepTouched Keep touched flags
keepDirty RHF keepDirty Keep dirty flags
keepDefaultValues RHF keepDefaultValues New values don’t become the new defaults
keepIsSubmitted RHF keepIsSubmitted Keep RHF’s isSubmitted flag
keepSubmitCount RHF keepSubmitCount Keep 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 — the same sanitizeHtml that labels and the html field go through (since 0.21 the html field is no longer an unsanitized escape hatch; code-authored forms opt out per block with .trustedHtml()).

The sanitizer is an allowlist rebuild: it tokenizes the markup with a strict grammar and re-emits only what it recognises — anything it cannot parse is escaped to text rather than “cleaned”. It is pure (no DOM), so server and client produce identical output. Policy:

  • Tags — formatting and structure (p, a, b, ul, table, img, details…). script, style, iframe, svg, math, object, form, template and other executing/embedding elements are removed with their content; unknown tags are removed and their text kept.
  • Attributes — allowlist plus data-*/aria-*. All on* handlers, style, name, srcdoc, formaction, ping, background are dropped.
  • URLshref/cite accept http, https, mailto, tel, sms and relative paths. <img src>/srcset accept https, relative paths and data:image/*; an external http: or //host image is dropped. Entities are decoded before the scheme check (&#106;avascript:, javascript&colon;) and control characters ignored.
  • target="_blank" gets rel="noopener noreferrer" added (existing rel tokens kept).
  • Comments, <!doctype>, processing instructions and malformed closing tags are dropped.

Hardened apps can replace the built-in sanitizer globally:

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

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

Post-submit redirect

config.redirect (string or (values) => string) navigates after a successful submit — but only to a URL that passes isSafeRedirectUrl: a path starting with / (not // or /\, which browsers resolve to another host) or an absolute http:///https:// URL. javascript:, data:, mailto:, protocol-relative and scheme-less values (thanks) are ignored with a console.warn. The helper is exported for your own navigation code:

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

isSafeRedirectUrl('/thanks');              // true
isSafeRedirectUrl('https://x.com/ok');     // true
isSafeRedirectUrl('javascript:alert(1)');  // false
isSafeRedirectUrl('//evil.example');       // false

  • 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