<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 thecomponentsprop 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:
onSubmitfires 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 callconfig.onError), so the native handler never sees them.onErrorfires only on that same native path, and — because pipeline errors are swallowed internally — in practice only when your ownonSubmitcallback 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.
handleSubmitfrom react-hook-form callspreventDefault(), so a working page still submits through the usualfetchpath — the one that speaksapplication/jsonand 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 acceptsapplication/jsononly as of today, so pointingactionat 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 — seemanualsubmit 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,templateand other executing/embedding elements are removed with their content; unknown tags are removed and their text kept. - Attributes — allowlist plus
data-*/aria-*. Allon*handlers,style,name,srcdoc,formaction,ping,backgroundare dropped. - URLs —
href/citeaccepthttp,https,mailto,tel,smsand relative paths.<img src>/srcsetaccepthttps, relative paths anddata:image/*; an externalhttp:or//hostimage is dropped. Entities are decoded before the scheme check (javascript:,javascript:) and control characters ignored. target="_blank"getsrel="noopener noreferrer"added (existingreltokens 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
Related
- 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 Reference —
FormConfigand every other exported type - HubForm — A wrapper around
<Form>that fetches its config from the hosted submit service