@letar/forms

Field Watchers

React to field changes with onFieldChange and Form.Watch

Overview

Sometimes a field change should trigger a side effect — auto-generate a slug, update a currency, recalculate a total. Two complementary APIs cover this:

APIStyleBest for
onFieldChange propConfiguration object on <Form>Simple field→field sync at the form level
<Form.Watch>Renderless component inside formLocal reactions, group-aware watchers

Both receive the same FieldChangeApi:

interface FieldChangeApi {
  setFieldValue(name: string, value: unknown): void
  getFieldValue(name: string): unknown
  getValues(): Record<string, unknown>
}

Live Demo

Full example

The complete sandbox example, read directly from the form-develop-app source at build time — switch framework/skin to see onFieldChange wired to a real form. No shadcn/Vue/Angular equivalent exists yet — those tabs show as disabled.

apps/form-develop-app/src/app/field-change-demo/page.tsx
'use client'

import { Box, Code, Heading, Text, VStack } from '@chakra-ui/react'
import { Form } from '@letar/forms'
import { useState } from 'react'
import { z } from 'zod/v4'
import { DemoPageLayout, SubmittedDataPreview } from '../_components'

/**
 * Простая транслитерация для демонстрации onFieldChange
 */
function transliterate(text: string): string {
  const map: Record<string, string> = {
    а: 'a',
    б: 'b',
    в: 'v',
    г: 'g',
    д: 'd',
    е: 'e',
    ё: 'yo',
    ж: 'zh',
    з: 'z',
    и: 'i',
    й: 'y',
    к: 'k',
    л: 'l',
    м: 'm',
    н: 'n',
    о: 'o',
    п: 'p',
    р: 'r',
    с: 's',
    т: 't',
    у: 'u',
    ф: 'f',
    х: 'kh',
    ц: 'ts',
    ч: 'ch',
    ш: 'sh',
    щ: 'shch',
    ъ: '',
    ы: 'y',
    ь: '',
    э: 'e',
    ю: 'yu',
    я: 'ya',
    ' ': '-',
  }
  return text
    .toLowerCase()
    .split('')
    .map((c) => map[c] ?? c)
    .join('')
    .replace(/[^a-z0-9-]+/g, '')
    .replace(/-+/g, '-')
    .replace(/^-|-$/g, '')
}

// --- Пример 1: onFieldChange prop ---

const CitySchema = z
  .object({
    name: z.string().min(1, 'Обязательное поле'),
    slug: z.string().min(1, 'Обязательное поле'),
    population: z.number().optional(),
  })
  .strip()

type CityData = z.infer<typeof CitySchema>

const cityInitial: CityData = { name: '', slug: '' }

// --- Пример 2: Form.Watch ---

const CountrySchema = z
  .object({
    country: z.enum(['RU', 'US', 'EU', 'JP']),
    currency: z.string(),
    greeting: z.string(),
  })
  .strip()

type CountryData = z.infer<typeof CountrySchema>

const countryInitial: CountryData = { country: 'RU', currency: 'RUB', greeting: 'Привет' }

const currencyMap: Record<string, string> = {
  RU: 'RUB',
  US: 'USD',
  EU: 'EUR',
  JP: 'JPY',
}

const greetingMap: Record<string, string> = {
  RU: 'Привет',
  US: 'Hello',
  EU: 'Bonjour',
  JP: 'こんにちは',
}

/**
 * Демо-страница для onFieldChange и Form.Watch
 */
export default function FieldChangeDemoPage() {
  const [submitted1, setSubmitted1] = useState<CityData | null>(null)
  const [submitted2, setSubmitted2] = useState<CountryData | null>(null)
  const [changeLog, setChangeLog] = useState<string[]>([])

  const addLog = (msg: string) => {
    setChangeLog((prev) => [...prev.slice(-9), msg])
  }

  return (
    <DemoPageLayout
      title="Field Change Demo"
      description="onFieldChange prop и Form.Watch компонент для реактивных побочных эффектов"
    >
      <VStack gap={8} align="stretch">
        {/* Пример 1: onFieldChange */}
        <Box>
          <Heading size="md" mb={4}>
            1. onFieldChange — автогенерация slug
          </Heading>
          <Text mb={4} color="fg.muted">
            При вводе названия автоматически генерируется slug через транслитерацию.
          </Text>

          <Form
            schema={CitySchema}
            initialValue={cityInitial}
            onSubmit={(data) => setSubmitted1(data)}
            onFieldChange={{
              name: (value, { setFieldValue }) => {
                addLog(`onFieldChange: name → "${value}"`)
                setFieldValue('slug', transliterate(String(value ?? '')))
              },
            }}
          >
            <VStack gap={4} align="stretch">
              <Form.Field.String name="name" label="Название города" />
              <Form.Field.String name="slug" label="Slug (автогенерация)" />
              <Form.Field.Number name="population" label="Население" />
              <Form.Button.Submit>Сохранить город</Form.Button.Submit>
            </VStack>
          </Form>

          {submitted1 && <SubmittedDataPreview data={submitted1} />}
        </Box>

        {/* Пример 2: Form.Watch */}
        <Box>
          <Heading size="md" mb={4}>
            2. Form.Watch — страна → валюта + приветствие
          </Heading>
          <Text mb={4} color="fg.muted">
            При выборе страны автоматически обновляются валюта и приветствие.
          </Text>

          <Form schema={CountrySchema} initialValue={countryInitial} onSubmit={(data) => setSubmitted2(data)}>
            <VStack gap={4} align="stretch">
              <Form.Field.NativeSelect
                name="country"
                label="Страна"
                options={[
                  { value: 'RU', title: 'Россия' },
                  { value: 'US', title: 'США' },
                  { value: 'EU', title: 'Франция' },
                  { value: 'JP', title: 'Япония' },
                ]}
              />
              <Form.Field.String name="currency" label="Валюта (автозаполнение)" />
              <Form.Field.String name="greeting" label="Приветствие (автозаполнение)" />

              <Form.Watch
                field="country"
                onChange={(value, { setFieldValue }) => {
                  const country = String(value)
                  addLog(`Form.Watch: country → "${country}"`)
                  setFieldValue('currency', currencyMap[country] ?? '')
                  setFieldValue('greeting', greetingMap[country] ?? '')
                }}
              />

              <Form.Button.Submit>Сохранить</Form.Button.Submit>
            </VStack>
          </Form>

          {submitted2 && <SubmittedDataPreview data={submitted2} />}
        </Box>

        {/* Лог изменений */}
        {changeLog.length > 0 && (
          <Box p={4} bg="bg.subtle" borderRadius="md">
            <Heading size="sm" mb={2}>
              Лог изменений
            </Heading>
            <VStack gap={1} align="stretch">
              {changeLog.map((log, i) => (
                <Code key={i} fontSize="sm">
                  {log}
                </Code>
              ))}
            </VStack>
          </Box>
        )}
      </VStack>
    </DemoPageLayout>
  )
}

onFieldChange

Pass a record of field names to callbacks on the <Form> root:

<Form
  schema={CitySchema}
  initialValue={{ name: '', slug: '' }}
  onSubmit={save}
  onFieldChange={{
    name: (value, { setFieldValue }) => {
      setFieldValue('slug', transliterate(String(value)))
    },
  }}
>
  <Form.Field.String name="name" label="City name" />
  <Form.Field.String name="slug" label="Slug" />
  <Form.Button.Submit />
</Form>

The callback fires after the TanStack Form store updates, synchronously. Only watched fields trigger their callbacks — other field changes are ignored.

Multiple watchers

onFieldChange={{
  quantity: (val, { setFieldValue, getFieldValue }) => {
    const price = getFieldValue('price') as number
    setFieldValue('total', (val as number) * price)
  },
  price: (val, { setFieldValue, getFieldValue }) => {
    const qty = getFieldValue('quantity') as number
    setFieldValue('total', qty * (val as number))
  },
}}

Form.Watch

A renderless component placed anywhere inside the form tree. It respects Form.Group context — field paths are resolved relative to the current group.

<Form schema={Schema} initialValue={data} onSubmit={save}>
  <Form.Field.NativeSelect name="country" options={countries} />
  <Form.Field.String name="currency" />

  <Form.Watch
    field="country"
    onChange={(value, { setFieldValue }) => {
      setFieldValue('currency', currencyMap[String(value)])
    }}
  />

  <Form.Button.Submit />
</Form>

Inside a group

Form.Watch resolves the field path relative to the parent group, just like Form.Field.*:

<Form.Group name="shipping">
  <Form.Field.String name="country" />
  <Form.Field.String name="currency" />

  {/* Watches shipping.country, not root country */}
  <Form.Watch
    field="country"
    onChange={(value, { setFieldValue }) => {
      // setFieldValue uses absolute paths
      setFieldValue('shipping.currency', getCurrency(String(value)))
    }}
  />
</Form.Group>

Props

PropTypeDescription
fieldstringField name to watch (relative to group)
onChange(value: unknown, api: FieldChangeApi) => voidCallback on value change

When to use which

ScenarioRecommended
Slug from titleonFieldChange — simple 1:1 mapping
Country → currency + greetingEither — onFieldChange for multiple fields at once, or Form.Watch if colocated with the select
Watcher inside Form.GroupForm.Watch — automatic path resolution
Dynamic calculation (price × qty)onFieldChange — access multiple fields via getFieldValue
Existing form, minimal changesonFieldChange — just add a prop, no JSX changes

Comparison with other patterns

vs useFieldActions + useEffect

Before onFieldChange, the manual approach was:

// Before — verbose custom component
function SlugSync() {
  const name = useFieldActions('name')
  const slug = useFieldActions('slug')

  useEffect(() => {
    slug.onChange(transliterate(String(name.value)))
  }, [name.value])

  return null
}

Now:

// After — one prop
<Form onFieldChange={{ name: (v, { setFieldValue }) => setFieldValue('slug', transliterate(String(v))) }}>

vs Form.When

Form.When controls visibility — show/hide fields based on a value. Form.Watch and onFieldChange control values — update other fields when one changes. They complement each other.


Live Example

Try the interactive example on forms-example.letar.best.

On this page