Building production-ready forms in a SaaS application means managing field state, validation (on blur, change, or submit), error messages, and a reliable "form valid" state for submit buttons. Doing this consistently across contact forms, try-it forms, reply forms, and data-request flows usually leads to duplicated logic and scattered validation rules. The Fastack ecosystem now includes @saas/form —a shared package that provides config-driven form state, configurable validation triggers, and built-in validators so you can define each form once and use the config as the single source of truth for both validation and UI—saving you weeks of form boilerplate.
Why a shared form package?
In a monorepo with multiple apps (e.g. a main SaaS app and a contact or support app), forms tend to repeat the same patterns: required fields, length limits, email format, and when to show errors (on blur vs on change vs on submit). Without a shared layer, every form reimplements validation and state, and the submit button often relies on ad-hoc checks instead of a single "form valid" state. A shared package gives you:
- One config per form — Labels, placeholders, mandatory flags, and validators live in one place (e.g. a
*-form-configuration.tsfile next to the page). - Consistent validation behaviour — Same triggers (blur, change, submit) and same rules (e.g. name pattern, email format) across all forms.
- Form valid state for the submit CTA —
isFormValidis derived from per-field validities so the submit button can be disabled until the form is valid, without duplicating checks. - Reusable validators and constants — Length limits and regex patterns (e.g. name, email) live in the package so API validation and client validation stay in sync.
Config as a record
Form config is a record keyed by field name (e.g. name, email, message). Each value describes the field: kind (text, email, textarea, radio, select, checkbox), label, placeholder, mandatory, and validators. The UI reads from this config so you never duplicate labels or placeholders in the JSX.
Example: contact form config
import {
mandatory,
minLength,
maxLength,
name as nameValidator,
email,
NAME_MIN_LENGTH,
NAME_MAX_LENGTH,
EMAIL_MAX_LENGTH,
MESSAGE_MIN_LENGTH,
MESSAGE_MAX_LENGTH,
type FormConfig,
} from '@saas/form';
export function getContactFormConfig(): FormConfig {
return {
name: {
kind: 'text',
label: 'Name',
placeholder: 'Your name',
mandatory: true,
validators: [
mandatory('Name is required'),
minLength(NAME_MIN_LENGTH, `Name must be ${NAME_MIN_LENGTH}–${NAME_MAX_LENGTH} characters`),
maxLength(NAME_MAX_LENGTH, `...`),
nameValidator(),
],
},
email: {
kind: 'email',
label: 'Email',
placeholder: '[email protected]',
mandatory: true,
validators: [
mandatory('Email is required'),
maxLength(EMAIL_MAX_LENGTH, 'Email is too long'),
email('Invalid email'),
],
},
message: {
kind: 'textarea',
label: 'Message',
placeholder: 'Your message',
mandatory: true,
minRows: 4,
validators: [
mandatory('Message is required'),
minLength(MESSAGE_MIN_LENGTH, `...`),
maxLength(MESSAGE_MAX_LENGTH, `...`),
],
},
} as FormConfig;
}Validation triggers and form valid state
You choose when validation runs via validateOn: onBlur, onChange, and/or onSubmit. The default is ['onBlur', 'onSubmit'] so errors appear after the user leaves a field or tries to submit, without validating on every keystroke. On mount, the package runs an initial validation to set the form valid state (so the submit button is correct from the start) but does not show error messages—except for fields that have a defaultValue, where invalid prefilled values show errors immediately.
- validities — Per-field validity (true = valid). Updated on initial run, on blur/change when configured, and on submit.
- isFormValid — True only when every field is valid. Use it to disable the submit CTA.
- errors — Only set when user-facing validation runs (blur/change/submit), so you avoid showing errors before the user has interacted.
Using the form in your component
Call useForm(config) and wrap your form in FormProvider. Bind inputs to formState.values, formState.errors, and formState.setValue / setTouched. Use formState.config.name (and similarly for other fields) for label and placeholder so the config remains the single source of truth.
const config = useMemo(() => getContactFormConfig(), []);
const formState = useForm(config);
<FormProvider value={formState}>
<form onSubmit={handleSubmit}>
<Input
label={formState.config.name!.label}
placeholder={formState.config.name?.placeholder}
value={String(formState.values.name ?? '')}
onValueChange={(v) => formState.setValue('name', v)}
onBlur={() => formState.setTouched('name', true)}
isRequired={formState.config.name!.mandatory}
errorMessage={formState.errors.name}
isInvalid={!!formState.errors.name}
/>
{/* ... email, message ... */}
<Button
type="submit"
isDisabled={loading || !formState.isFormValid}
>
Send message
</Button>
</form>
</FormProvider>Built-in validators and constants
The package exports validators and generic constants so client and server can share the same rules:
- Validators —
mandatory,minLength,maxLength,pattern,email,name(display-name pattern). - Constants —
NAME_MIN_LENGTH,NAME_MAX_LENGTH,EMAIL_MAX_LENGTH,MESSAGE_MIN_LENGTH,MESSAGE_MAX_LENGTH,NAME_REGEX,EMAIL_REGEX.
Where it is used in the ecosystem
In the dialobox app (contact and form management), every major form uses @saas/form:
- Contact form — Name, email, message with a dedicated
contact-form-configuration.ts. - Try-it form — Same fields for testing a form definition;
try-it-form-configuration.ts. - Reply form — Single message field for token-based replies;
reply-form-configuration.ts. - Data-request form — Email and request type (radio);
data-request-form-configuration.ts.
Each form has one config file, one useForm call, and UI that reads from the config. The same package can be adopted by any other app in the Fastack ecosystem for consistent form behaviour and less duplicated code.
Ready to build your SaaS faster?
Get our scalable, production-ready boilerplate to save endless hours of development and setup
