react-formbridge
Browse documentation
Validationv1.0.2

Validation

react-formbridge ships with a complete, self-sufficient validation pipeline. No external library is required to build a production-grade form.

Every approach below flows through the same runtime and lands in the same state.errors bag. There are five complementary ways to validate, and you can combine any of them in the same form:

  1. Field-level builder rules - required, min, max, pattern/patterns, email, url, matches/sameAs, mustBeTrue, validate(fn), number helpers, file limits, phone formatting, etc. Declared directly on each builder, they cover most everyday rules.
  2. Schema-level cross-field validation via createSchema() - the recommended path the moment you need rules that span multiple fields (refine, superRefine, atLeastOne, exactlyOne, allOrNone, errorMap, async refinements, safeParse / validate). Zero external dependency required. See the dedicated createSchema() API section.
  3. Imperative validation from the runtime - validate(names?), setError(), clearErrors() let you trigger checks on demand and merge server-side errors into the same bag.
  4. Trigger configuration - validateOn and revalidateOn control when validation runs ('onBlur', 'onChange', 'onSubmit', 'onTouched'). Defaults: validateOn='onBlur', revalidateOn='onChange'.
  5. External bridges (opt-in) - bring your own Zod / Yup / Joi / Valibot schema via validatorBridge when you already own a domain schema elsewhere in the app.

FormBridge's design goal: built-in validation is the complete path, not a stepping stone to an external bridge. Bridges are provided for teams that already have a domain schema they want to reuse, not because the built-in pipeline is incomplete.

1. Field-level validation (builder rules)

Use builder methods when the rule belongs to the field itself. Every builder inherits a common base surface. See the Base field builder section for the full reference table.

Common rules available on most builders

MethodSignatureDescription
required(message?: string)The value must be provided
min(n: number, message?: string)Length (text) or numeric lower bound
max(n: number, message?: string)Length (text) or numeric upper bound
pattern(regex: RegExp, message?: string)Single regex check
patterns([{ regex, message }, ...])Multiple regex checks in order
matches(otherField: string, message?: string)Cross-field equality check
sameAs(otherField: string, message?: string)Alias of matches - confirm password style
validate((value, allValues) => string | null)Custom sync predicate, return null when valid
mustBeTrue(message?: string)Agreement toggles (checkbox / switch)

Specialised helpers on typed builders: email() built into field.email(), url() on field.url(), integer() / positive() / step() on field.number(), country / format rules on field.phone(), accept / maxSize / maxFiles on field.file(), length and character-class options on field.password(), etc.

Each typed builder has its own dedicated section with the exhaustive list of rules it supports (see the Fields sidebar group).

Validation entry points in the public runtime:

MethodTypeDescription
Builder rules-Field-level rules live on the builders themselves and are documented in the builder sections
validateOn'onBlur' | 'onChange' | 'onSubmit' | 'onTouched'First validation trigger
revalidateOn'onBlur' | 'onChange' | 'onSubmit' | 'onTouched'Follow-up trigger after interaction
validate(names?) => Promise<boolean>Trigger validation imperatively
setError / clearErrors(name, message) => voidMerge server-side validation into the same runtime
validatorBridge(values) => { values, errors }Let an external schema engine own the final { values, errors } result
field-level-rules.tsxtsx
1import { field, useFormBridge } from '@runilib/react-formbridge'
2
3const signupSchema = {
4 displayName: field
5 .text('Display name')
6 .required('Choose a display name')
7 .min(2, 'Use at least 2 characters')
8 .max(40),
9
10 email: field.email('Email').required(),
11
12 age: field.number('Age').required().integer().min(13).max(120),
13
14 password: field
15 .password('Password')
16 .required()
17 .min(8)
18 .pattern(/[A-Z]/, 'Needs an uppercase letter'),
19
20 confirmPassword: field
21 .password('Confirm password')
22 .required()
23 .sameAs('password', 'Passwords do not match'),
24
25 terms: field.checkbox('I accept the terms').mustBeTrue('You must accept the terms'),
26}

2. Schema-level validation - createSchema() (recommended for cross-field rules)

Wrap your shape with createSchema() the moment you need rules that depend on more than one field, form-level errors, parsing utilities, async refinements, or global error message mapping. The wrapped value is still handed straight to useFormBridge, so you never split rendering and validation across two objects.

Cross-field primitives exposed by `createSchema()`

MethodSignatureDescription
refine(predicate, message?)Boolean predicate, raises one issue if it fails
refineAsync(predicate, message?)Async boolean predicate (promise-returning)
superRefine((values, ctx) => void)Raise multiple issues in a single pass, each routed to its own field
atLeastOne(fields: string[], message?)At least one of the listed fields must be provided
exactlyOne(fields: string[], message?)Exactly one of the listed fields must be provided
allOrNone(fields: string[], message?)Either all or none of the listed fields are provided
errorMap((issue, defaultMessage) => string)Global message transformer (i18n, copy standardisation)
ref('path.to.field')Typed pointer for nested or dynamic field paths

Parsing utilities (usable anywhere, not just inside React)

MethodSignatureDescription
safeParse(values) => ValidationResultNever throws - returns { success, data, errorsByField, formLevelErrors, issues }
safeParseAsync(values) => Promise<ValidationResult>Async variant, runs sync + async refinements
validate(values) => dataStrict - throws FormBridgeSchemaValidationError on failure
validateAsync(values) => Promise<data>Async strict variant

Form-level errors (any issue without a path) surface under state.formLevelError. Field-level issues land in state.errors[fieldName] like every other rule.

If the schema object lives in a module that is also imported by server-side code, define that module with @runilib/react-formbridge/schema and keep useFormBridge imported from the main package in your client components.

For the complete API reference, options, signatures and examples, see the dedicated createSchema() API section.

schema-validation.tsts
1import { createSchema, field } from '@runilib/react-formbridge'
2
3export const tripSchema = createSchema({
4 email: field.email().label('Email'),
5 phone: field.phone('FR').label('Phone'),
6 password: field.password().required().min(8),
7 confirmPassword: field.password().required(),
8})
9 .atLeastOne(['email', 'phone'], 'Provide at least an email or a phone.')
10 .superRefine((values, ctx) => {
11 if (values.password !== values.confirmPassword) {
12 ctx.addIssue({
13 path: 'confirmPassword',
14 code: 'password_mismatch',
15 message: 'Passwords do not match.',
16 })
17 }
18 })
19 .errorMap((issue, defaultMessage) => {
20 if (issue.code === 'required') return 'This field is required.'
21 return defaultMessage
22 })

3. Imperative validation & server-side errors

The runtime returned by useFormBridge exposes imperative entry points so you can drive validation from your own code - submit handlers, effects, or server round-trips.

Method / StateSignatureDescription
validate(names?: string | string[]) => Promise<Errors>Run validation on demand. Pass one field, an array, or nothing for the whole form. Returns the up-to-date error map
setError(name: string, message: string) => voidPush a single error into the state - ideal for surfacing one server-side error
setErrors(errorsByField: Record<string, string>) => voidMerge a bag of server errors in one call
clearErrors(names?: string | string[]) => voidClear one, several, or all errors
state.errorsRecord<string, string>Current error map (read from React)
state.touchedRecord<string, boolean>Which fields have been blurred at least once
state.dirtyRecord<string, boolean>Which fields differ from their initial value
state.formLevelErrorstring | nullForm-level error produced by createSchema() refinements

This is also how you wire server-side validation into the same pipeline: call your API inside onSubmit, then setErrors(response.errorsByField) to surface any failures under the same field keys your users are already looking at.

imperative-validation.tsxtsx
1const { Form, fields, state, validate, setError, setErrors, clearErrors } =
2 useFormBridge(signupSchema)
3
4async function onSubmit(values) {
5 // Re-validate the whole form manually if needed
6 const errors = await validate()
7 if (Object.keys(errors).length > 0) return
8
9 const res = await api.signup(values)
10 if (!res.ok) {
11 // Merge server-side errors into the same state.errors bag
12 setErrors(res.errorsByField)
13 // Or push a single error under one field
14 setError('email', 'This email is already registered')
15 return
16 }
17 clearErrors()
18}

4. Trigger matrix - when validation runs

Accepted validation trigger values on useFormBridge({ validateOn, revalidateOn }):

TriggerDescription
'onBlur'Validate after blur
'onChange'Validate on every change
'onSubmit'Validate only on submit
'onTouched'Validate after first blur, then on every subsequent change

Runtime defaults

OptionDefaultDescription
validateOn'onBlur'First validation happens when the user leaves the field
revalidateOn'onChange'After first interaction, every keystroke re-runs validation

Pick 'onTouched' for the smoothest UX: no premature errors while the user is typing for the first time, instant feedback once they've committed. Pick 'onSubmit' for minimal interruptions on long forms.

triggers.tsxtsx
1const { Form, fields, state } = useFormBridge(signupSchema, {
2 validateOn: 'onTouched',
3 revalidateOn: 'onChange',
4})

5. External bridges (opt-in)

Use a bridge when you already own a domain schema elsewhere in the app (Zod, Yup, Joi, Valibot) and want to reuse it as the validation source of truth. The bridge receives the current values and returns { values, errors }, which the runtime merges into state.errors exactly like built-in validation.

BridgeSignatureDescription
zodBridge(schema, options?)Reuse an existing Zod schema
yupBridge(schema, options?)Reuse an existing Yup schema
joiBridge(schema, options?)Reuse an existing Joi schema
valibotBridge(schema, options?)Reuse an existing Valibot schema

All bridges share the same options surface documented in the Schema adapters section.

Shared adapter options (BridgeAdapterOptions) supported by all built-in bridges:

MethodTypeDescription
rootKey?string | nullWhere pathless errors land. Default '_root'. Set null to drop them
errorMode?'first' | 'join' | 'last'How duplicate field errors are aggregated
joinMessagesWith?stringSeparator for errorMode: 'join'
formatPath?(path, issue) => stringRewrite the final error key
mapIssue?(context) => Issue | nullRemap, skip, or rewrite an issue before it hits the error bag
normalizeMessage?(message, issue) => stringFinal message normalization hook

You do not need a bridge to get cross-field validation, async checks, or i18n - createSchema() covers all of that natively. Reach for a bridge only when you have an *existing* Zod/Yup/Joi/Valibot schema you want to reuse as-is.

bridge.tsxtsx
1import { z } from 'zod'
2import { field, useFormBridge, zodBridge } from '@runilib/react-formbridge'
3
4const zSchema = z.object({
5 email: z.string().email(),
6 password: z.string().min(8),
7})
8
9const formSchema = {
10 email: field.email(),
11 password: field.password(),
12}
13
14const { Form, fields, state } = useFormBridge(formSchema, {
15 validatorBridge: zodBridge(zSchema),
16})

Bonus - parsing outside the form (server actions, tests, utilities)

Because createSchema() exposes safeParse / safeParseAsync / validate / validateAsync, you can reuse the exact same schema outside of React - in server actions, tRPC procedures, background jobs, or tests - without mounting a form.

MethodSignatureDescription
safeParse(values) => ValidationResultSynchronous, never throws
safeParseAsync(values) => Promise<ValidationResult>Runs sync + async refinements - recommended for server-side
validate(values) => dataStrict variant - throws FormBridgeSchemaValidationError on failure
validateAsync(values) => Promise<data>Async strict variant

The returned ValidationResult carries errorsByField (drop-in compatible with state.errors) and formLevelErrors (array of form-level messages). See the createSchema() API section for the full result shape.

If that schema module is imported by server code, define it with @runilib/react-formbridge/schema so only the non-React surface is pulled into the server module graph.

tripSchema.ts + server-action.tsts
1// tripSchema.ts
2import { createSchema, field } from '@runilib/react-formbridge/schema'
3
4export const tripSchema = createSchema({
5 email: field.email('Email'),
6 phone: field.phone('FR').label('Phone'),
7})
8 .atLeastOne(['email', 'phone'], 'Provide at least an email or a phone.')
9
10// server-action.ts
11'use server'
12
13import { tripSchema } from './tripSchema'
14
15export async function bookTrip(raw: unknown) {
16 const result = await tripSchema.safeParseAsync(raw as any)
17 if (!result.success) {
18 return { ok: false as const, errors: result.errorsByField, formLevelErrors: result.formLevelErrors }
19 }
20 await db.bookings.insert(result.data)
21 return { ok: true as const }
22}