@letar/forms

Async Validation

Server-side async validation with debounce, cancellation, and caching

Full example

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

apps/form-develop-app/src/app/async-validation-demo/page.tsx
'use client'

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

/** Имитация серверной проверки email */
async function checkEmailAvailability(value: unknown): Promise<string | undefined> {
  await new Promise((r) => setTimeout(r, 800))
  const taken = ['admin@test.com', 'user@test.com', 'test@test.com']
  if (taken.includes(String(value))) {
    return 'Этот email уже зарегистрирован'
  }
  return undefined
}

/** Имитация серверной проверки username */
async function checkUsernameAvailability(value: unknown): Promise<string | undefined> {
  await new Promise((r) => setTimeout(r, 600))
  const taken = ['admin', 'root', 'test', 'user']
  if (taken.includes(String(value).toLowerCase())) {
    return 'Username занят'
  }
  return undefined
}

export default function AsyncValidationDemoPage() {
  const [submittedData, setSubmittedData] = useState<Record<string, unknown> | null>(null)

  return (
    <DemoPageLayout
      title="Async Validation"
      description="Серверная валидация с debounce, отменой запросов и кэшированием"
    >
      <VStack gap={8} align="stretch">
        <Box>
          <Heading size="md" mb={3}>
            Регистрация с проверкой уникальности
          </Heading>
          <Text fontSize="sm" color="fg.muted" mb={4}>
            Email проверяется на blur (занятые: admin@test.com, user@test.com, test@test.com). Username проверяется
            onChange с debounce 300мс (занятые: admin, root, test, user).
          </Text>
          <Form
            debug
            initialValue={{ username: '', email: '', password: '' }}
            onSubmit={(data) => setSubmittedData(data as Record<string, unknown>)}
          >
            <Form.Field.String
              name="username"
              label="Username"
              placeholder="Введите имя пользователя"
              asyncValidate={checkUsernameAvailability}
              asyncDebounce={300}
              asyncTrigger="onChange"
            />
            <Form.Field.String
              name="email"
              label="Email"
              placeholder="Введите email"
              asyncValidate={checkEmailAvailability}
              asyncDebounce={500}
              asyncTrigger="onBlur"
            />
            <Form.Field.Password name="password" label="Пароль" />
            <Form.Button.Submit>Зарегистрироваться</Form.Button.Submit>
          </Form>
        </Box>

        {submittedData && (
          <Box p={4} bg="bg.subtle" borderRadius="md">
            <Heading size="sm" mb={2}>
              Отправленные данные:
            </Heading>
            <pre style={{ fontSize: '12px', overflow: 'auto' }}>{JSON.stringify(submittedData, null, 2)}</pre>
          </Box>
        )}
      </VStack>
    </DemoPageLayout>
  )
}

Overview

Async validation lets you check values against a server (e.g., email uniqueness, username availability) while the user types or when they leave a field.

Via Props

<Form.Field.String
  name="email"
  label="Email"
  asyncValidate={async (value) => {
    const exists = await fetch(`/api/check-email?email=${value}`)
    if (exists) return 'Email already registered'
  }}
  asyncDebounce={500}
  asyncTrigger="onBlur"
/>

Via Zod .meta()

const Schema = z.object({
  email: z.email().meta({
    asyncValidate: async (value) => {
      const res = await fetch(`/api/check-email?email=${value}`)
      const { exists } = await res.json()
      if (exists) return 'Email already registered'
    },
    asyncDebounce: 500,
    asyncTrigger: 'onBlur',
  }),
})

Props

All fields that use createField support these props:

PropTypeDefaultDescription
asyncValidate(value: unknown) => Promise<string | undefined>Validation function
asyncDebouncenumber500Debounce delay (ms)
asyncTrigger'onBlur' | 'onChange''onBlur'When to trigger

Features

  • Debounce — waits for user to stop typing before sending request
  • Request cancellation — AbortController cancels previous request when new input arrives
  • Caching — already validated values are not re-checked
  • Offline-safe — skips async validation when navigator.onLine is false
  • Props priority — props override schema meta config

useAsyncFieldValidation Hook

For custom field components:

import { useAsyncFieldValidation } from '@letar/forms'

const { validators, asyncDebounceMs, hasAsyncValidation } = useAsyncFieldValidation(schema, 'email', {
  asyncValidate: myFn,
  asyncDebounce: 300,
})

Live Example

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

On this page