react-formbridge
Browse documentation
Interactives Tutorialsv1.0.2

Tutorial: validation & bridges

Validation is usually a mix of fast local UX rules and one stronger source of truth for business constraints.

  • Start with fluent builder rules when the rule clearly belongs to one field
  • Add conditional required or visibility rules in the schema instead of scattering conditions through JSX
  • Use a bridge when Zod, Yup, Joi, or Valibot already owns the canonical validation contract
  • Use async options when a field depends on remote search or lookup data

Builder rules and cross-field logic

This is the fastest path for forms whose rules live mostly in the frontend or belong to one field at a time.

ValidationRules.tsxtsx
1const schema = {
2 email: field.email('Work email').required().trim().lowercase(),
3 password: field.password('Password').required().strong(),
4 confirmPassword: field
5 .password('Confirm password')
6 .required()
7 .sameAs('password', 'Passwords must match'),
8 accountType: field.select('Account type').options([
9 { label: 'Personal', value: 'personal' },
10 { label: 'Company', value: 'company' },
11 ]).required(),
12 companyName: field
13 .text('Company name')
14 .visibleWhen('accountType', 'company')
15 .requiredWhen('accountType', 'company')
16 .clearOnHide(),
17}
18
19const form = useFormBridge(schema, {
20 validateOn: 'onBlur',
21 revalidateOn: 'onChange',
22})

Schema adapters

Use a bridge when the backend or another package already shares a validation schema with the frontend.

zod-bridge.tsts
1import { z } from 'zod'
2import { field, useFormBridge, zodBridge } from '@runilib/react-formbridge'
3
4const schema = {
5 email: field.email('Email').required(),
6 password: field.password('Password').required(),
7}
8
9const zodSchema = z.object({
10 email: z.string().email(),
11 password: z.string().min(8),
12})
13
14const form = useFormBridge(schema, {
15 validatorBridge: zodBridge(zodSchema),
16})

Async lookups and remote options

Remote search belongs in a different lane than validation, but in real products they often meet in the same field.

  • The interactive examples below use mocked city data so the playground behaves like a real lookup flow without depending on an unavailable demo API
import { useState } from 'react'
import { useAsyncOptions } from '@runilib/react-formbridge'

const CITY_DB = {
  FR: ['Paris', 'Lyon', 'Marseille', 'Bordeaux', 'Lille'],
  US: ['New York', 'San Francisco', 'Chicago', 'Seattle', 'Austin'],
  GB: ['London', 'Manchester', 'Bristol', 'Leeds', 'Edinburgh'],
}

const cityFetcher = async ({ search, deps, signal }) => {
  await new Promise((resolve, reject) => {
    const timeoutId = setTimeout(resolve, 450)

    signal.addEventListener(
      'abort',
      () => {
        clearTimeout(timeoutId)
        reject(new DOMException('Aborted', 'AbortError'))
      },
      { once: true },
    )
  })

  const allCities = CITY_DB[deps.country] ?? []
  const normalized = search.trim().toLowerCase()

  return allCities
    .filter((city) => city.toLowerCase().includes(normalized))
    .map((city) => ({
      value: city.toLowerCase().replace(/\s+/g, '-'),
      label: city,
    }))
}

export function CitySelect() {
  const [country, setCountry] = useState('FR')

  const cities = useAsyncOptions({
    key: 'cities',
    fetch: cityFetcher,
    dependsOn: ['country'],
    debounce: 250,
    minChars: 2,
    fetchOnMount: false,
    cacheTtl: 5 * 60_000,
    keepPreviousOptions: true,
  }, { country })

  const canSearch = cities.search.trim().length >= 2
  const loadingLabel = canSearch && cities.loading
    ? cities.options.length > 0
      ? 'Refreshing results...'
      : 'Loading...'
    : null

  return (
    <div style={{ fontFamily: 'sans-serif', padding: 20, background: '#f5f7fb' }}>
      <div style={{ display: 'flex', gap: 8, flexWrap: 'wrap', marginBottom: 12 }}>
        {['FR', 'US', 'GB'].map((nextCountry) => (
          <button
            key={nextCountry}
            type="button"
            onClick={() => setCountry(nextCountry)}
            style={{ fontWeight: country === nextCountry ? 700 : 500 }}
          >
            {nextCountry}
          </button>
        ))}
      </div>

      <p style={{ marginTop: 0, color: '#4b5563' }}>
        Country: <strong>{country}</strong>
      </p>

      <input
        placeholder="Type at least 2 characters"
        value={cities.search}
        onChange={(e) => cities.setSearch(e.target.value)}
        style={{
          width: '100%',
          maxWidth: 320,
          padding: '10px 12px',
          borderRadius: 10,
          border: '1px solid #cbd5e1',
          marginBottom: 12,
        }}
      />
      {!canSearch ? <p>Type at least 2 characters</p> : null}
      {loadingLabel ? <p>{loadingLabel}</p> : null}
      {cities.error ? <p>{cities.error}</p> : null}
      <ul style={{ margin: 0, paddingLeft: 18 }}>
        {cities.options.map((option) => (
          <li key={option.value}>{option.label}</li>
        ))}
      </ul>
    </div>
  )
}

export default CitySelect