@reformer/core 4.0.0 → 5.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -34,4 +34,4 @@ import { FieldPathNode } from '../../types';
34
34
  * }
35
35
  * ```
36
36
  */
37
- export declare function email<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions): void;
37
+ export declare function email<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions): void;
@@ -37,4 +37,4 @@ import { FieldPathNode } from '../../types';
37
37
  * }
38
38
  * ```
39
39
  */
40
- export declare function pattern<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, regex: RegExp, options?: ValidateOptions): void;
40
+ export declare function pattern<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, regex: RegExp, options?: ValidateOptions): void;
@@ -21,7 +21,7 @@ export type PhoneFormat = 'international' | 'ru' | 'us' | 'any';
21
21
  * phone(path.phoneNumber, { format: 'international', message: 'Неверный формат телефона' });
22
22
  * ```
23
23
  */
24
- export declare function phone<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
24
+ export declare function phone<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
25
25
  /** Формат телефона */
26
26
  format?: PhoneFormat;
27
27
  }): void;
@@ -14,7 +14,7 @@ import { FieldPathNode } from '../../types';
14
14
  * url(path.website, { requireProtocol: true });
15
15
  * ```
16
16
  */
17
- export declare function url<TForm, TField extends string | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
17
+ export declare function url<TForm, TField extends string | null | undefined = string>(fieldPath: FieldPathNode<TForm, TField> | undefined, options?: ValidateOptions & {
18
18
  /** Требовать наличие протокола (http:// или https://) */
19
19
  requireProtocol?: boolean;
20
20
  /** Разрешенные протоколы */
package/llms.txt CHANGED
@@ -34,6 +34,8 @@
34
34
  - 28-submit-and-reset.md — Submit и Reset — Жизненный цикл отправки формы
35
35
  - 29-async-preload.md — Async Preload — Загрузка начальных значений и динамических справочников
36
36
  - 30-type-safety-recipes.md — type-safety-recipes
37
+ - 31-async-validator-debounce.md — async-validator-debounce
38
+ - 32-async-options-loading.md — async-options-loading
37
39
  - API Reference (auto-generated from JSDoc)
38
40
 
39
41
  ## 1. 1. API Reference
@@ -81,23 +83,38 @@ const { value, errors, disabled } = useFormControl(control.loanType);
81
83
 
82
84
  ## 2. 1.5 QUICK START - Minimal Working Form
83
85
 
86
+ > **Schema-driven UI rule (read first)**: компонент И его пропсы (label, placeholder,
87
+ > options, type) объявляются в **схеме поля** (`component` + `componentProps`).
88
+ > В JSX рендерится один универсальный `<FormField control={form.x} />` из
89
+ > `@reformer/ui-kit` БЕЗ дополнительных props. Не пиши свои `Input`/`Select`/
90
+ > `Checkbox`-обёртки с `label`-prop'ами — это anti-pattern. См.
91
+ > `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`.
92
+
84
93
  ```typescript
85
- import { createForm, useFormControl } from '@reformer/core';
94
+ import { createForm, type FormProxy, type FormSchema } from '@reformer/core';
86
95
  import { required, email } from '@reformer/core/validators';
87
- import type { FormProxy } from '@reformer/core';
96
+ import { FormField, Input, Button } from '@reformer/ui-kit';
88
97
 
89
- // 1. Define form type
90
- interface ContactForm {
98
+ // 1. Define form type as `type` alias (not `interface` — see Recipe 2)
99
+ type ContactForm = {
91
100
  name: string;
92
101
  email: string;
93
- }
102
+ };
94
103
 
95
- // 2. Create form schema with validation
104
+ // 2. Schema: component + componentProps decl in fields, no JSX label props
96
105
  const form = createForm<ContactForm>({
97
106
  form: {
98
- name: { value: '', component: Input },
99
- email: { value: '', component: Input },
100
- },
107
+ name: {
108
+ value: '',
109
+ component: Input,
110
+ componentProps: { label: 'Name', placeholder: 'Your name' },
111
+ },
112
+ email: {
113
+ value: '',
114
+ component: Input,
115
+ componentProps: { label: 'Email', type: 'email' },
116
+ },
117
+ } satisfies FormSchema<ContactForm>,
101
118
  validation: (path) => {
102
119
  required(path.name, { message: 'Name is required' });
103
120
  required(path.email, { message: 'Email is required' });
@@ -105,52 +122,68 @@ const form = createForm<ContactForm>({
105
122
  },
106
123
  });
107
124
 
108
- // 3. Use in React component
125
+ // 3. Use in React component — thin JSX, FormField does ALL heavy lifting
109
126
  function ContactFormComponent() {
110
- const nameCtrl = useFormControl(form.name);
111
- const emailCtrl = useFormControl(form.email);
112
-
113
127
  const handleSubmit = async () => {
114
- await form.submit((values) => {
128
+ await form.submit((values: ContactForm) => {
115
129
  console.log('Form submitted:', values);
116
130
  });
117
131
  };
118
132
 
119
133
  return (
120
134
  <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
121
- <div>
122
- <input
123
- value={nameCtrl.value}
124
- onChange={(e) => form.name.setValue(e.target.value)}
125
- disabled={nameCtrl.disabled}
126
- />
127
- {nameCtrl.errors.map((err) => <span key={err.code}>{err.message}</span>)}
128
- </div>
129
- <div>
130
- <input
131
- value={emailCtrl.value}
132
- onChange={(e) => form.email.setValue(e.target.value)}
133
- disabled={emailCtrl.disabled}
134
- />
135
- {emailCtrl.errors.map((err) => <span key={err.code}>{err.message}</span>)}
136
- </div>
137
- <button type="submit">Send</button>
135
+ <FormField control={form.name} testId="name" />
136
+ <FormField control={form.email} testId="email" />
137
+ <Button type="submit">Send</Button>
138
138
  </form>
139
139
  );
140
140
  }
141
141
 
142
142
  // 4. Pass form to child components via props (NOT context!)
143
- interface FormStepProps {
143
+ type FormStepProps = {
144
144
  form: FormProxy<ContactForm>;
145
- }
145
+ };
146
146
 
147
147
  function FormStep({ form }: FormStepProps) {
148
- // Access form fields directly
149
- const { value } = useFormControl(form.name);
150
- return <div>Name: {value}</div>;
148
+ return <FormField control={form.name} testId="name" />;
151
149
  }
152
150
  ```
153
151
 
152
+ ### Arrays of objects — tuple format
153
+
154
+ ⚠️ Массивы объектов в схеме объявляются **через tuple `[itemSchema]`**, НЕ через `FieldConfig` с `value: []`:
155
+
156
+ ```typescript
157
+ // ❌ DON'T — TS error: Type 'FieldConfig<PropertyItem[]>' is not assignable to type '[FormSchema<PropertyItem>]'
158
+ const schema: FormSchema<{ properties: PropertyItem[] }> = {
159
+ properties: { value: [], component: Input }, // ← intuitive but WRONG
160
+ };
161
+
162
+ // ✅ DO — tuple [itemSchema]: ArrayNode infers item shape from первого элемента
163
+ const schema: FormSchema<{ properties: PropertyItem[] }> = {
164
+ properties: [
165
+ {
166
+ type: { value: 'apartment', component: Select, componentProps: { ... } } satisfies FieldConfig<PropertyType>,
167
+ description: { value: '', component: Textarea, componentProps: { label: 'Описание' } },
168
+ estimatedValue: { value: 0, component: Input, componentProps: { label: 'Стоимость', type: 'number' } },
169
+ },
170
+ ],
171
+ };
172
+ ```
173
+
174
+ В runtime массив пуст (`form.properties.value === []`); tuple — это **template** для каждого item, который применяется при `form.properties.push()` / `.insert()`. См. подробности в `find_recipe(topic="form-array")` и `find_recipe(topic="array-operations")`.
175
+
176
+ ### When to write your own field components (advanced — rare)
177
+
178
+ Свои компоненты нужны ТОЛЬКО если:
179
+
180
+ - ты намеренно избегаешь `@reformer/ui-kit` (например, проект уже имеет свою design system)
181
+ - нужен особый низкоуровневый input, который не покрывается `FormField` + `componentProps`
182
+
183
+ В этом случае см. секцию `## 14.5 UI COMPONENT PATTERNS` ниже — но даже там
184
+ паттерн **schema-driven** (label/options не из JSX-props, а из `componentProps` через
185
+ `useFormControl(...).componentProps`).
186
+
154
187
  ## 3. 2. API SIGNATURES
155
188
 
156
189
  ### Validators
@@ -497,10 +530,46 @@ const behavior: BehaviorSchemaFn<LoanForm> = (path) => {
497
530
  };
498
531
  ```
499
532
 
500
- Reference patterns: `complex-multy-step-form/schemas/credit-application-behavior.ts` and `mcp-credit-application-v10/schema.ts` (Compute helpers section).
533
+ Reference patterns: `complex-multy-step-form/schemas/credit-application-behavior.ts`.
501
534
 
502
535
  ## 6. 4. COMMON MISTAKES
503
536
 
537
+ ### TSC overload-resolution error: `'form' does not exist in FormSchema<T>`
538
+
539
+ **Симптом**:
540
+
541
+ ```
542
+ TS2769: Object literal may only specify known properties, and 'form' does not
543
+ exist in type 'FormSchema<MyForm>'.
544
+ ```
545
+
546
+ (или похожее с `validation` / `behavior` ключами).
547
+
548
+ **Причина**: `createForm` имеет 2 overload — `GroupNodeConfig<T>` (с `form/validation/behavior`)
549
+ и schema-only. Если в inline literal `form: { ... }` есть глубокая inference-проблема
550
+ (чаще всего — literal default value у union-типа поля, см. Recipe 8 в
551
+ [30-type-safety-recipes.md](30-type-safety-recipes.md)), TS отбрасывает Overload 1 и
552
+ матчит Overload 2 (schema-only). В нём ключа `form` нет — получаешь misleading error.
553
+
554
+ **Workaround** — extract `form` literal в typed local:
555
+
556
+ ```ts
557
+ const form: FormSchema<MyForm> = {
558
+ fieldA: { value: 'foo', component: Input /* ... */ },
559
+ // ...
560
+ };
561
+
562
+ createForm({ form, validation, behavior });
563
+ ```
564
+
565
+ Теперь TS репортит **реальную** per-field ошибку — например:
566
+
567
+ ```
568
+ TS2322: Type '"male"' is not assignable to type 'Gender | null'
569
+ ```
570
+
571
+ После этого решай конкретную проблему (Recipe 8 — `satisfies FieldConfig<UnionType>`).
572
+
504
573
  ### Imports rule (#1 cause of cascading errors — read first)
505
574
 
506
575
  Types live in `@reformer/core`. Functions live in submodules (`/validators`,
@@ -1286,105 +1355,185 @@ export const creditApplicationValidation: ValidationSchemaFn<Form> = (path) => {
1286
1355
 
1287
1356
  ## 17. 14.5 UI COMPONENT PATTERNS
1288
1357
 
1289
- ReFormer does NOT provide UI components - you create them yourself or use a UI library.
1358
+ > **Default rule (read first)**: для UI используй `FormField` из
1359
+ > [`@reformer/ui-kit`](../../reformer-ui-kit/) — он покрывает 95% случаев одной
1360
+ > строкой `<FormField control={form.x} />`. Свои field-обёртки пиши ТОЛЬКО если
1361
+ > ui-kit не подходит (другая design system, особый low-level input).
1362
+ >
1363
+ > Канонический schema-driven подход:
1364
+ >
1365
+ > - **компонент** объявляется в схеме как `component: Input` (или `Select`, `Checkbox`, etc.)
1366
+ > - **пропсы** компонента — в `componentProps: { label, placeholder, options, type, ... }`
1367
+ > - **JSX рендерит**: `<FormField control={form.x} />` БЕЗ дополнительных props
1368
+ >
1369
+ > См. `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`
1370
+ > для полного руководства.
1290
1371
 
1291
- ### Generic FormField Component
1372
+ ### Default FormField из ui-kit (canonical)
1292
1373
 
1293
1374
  ```tsx
1294
- import type { FieldNode } from '@reformer/core';
1295
- import { useFormControl } from '@reformer/core';
1375
+ import { useMemo } from 'react';
1376
+ import { createForm, type FormSchema } from '@reformer/core';
1377
+ import { FormField, Input, Select, Checkbox, Button } from '@reformer/ui-kit';
1296
1378
 
1297
- interface FormFieldProps<T> {
1298
- control: FieldNode<T>;
1299
- label?: string;
1300
- type?: 'text' | 'email' | 'number' | 'password';
1301
- placeholder?: string;
1302
- }
1379
+ type RegistrationForm = {
1380
+ email: string;
1381
+ country: string;
1382
+ agree: boolean;
1383
+ };
1303
1384
 
1304
- function FormField<T extends string | number>({
1305
- control,
1306
- label,
1307
- type = 'text',
1308
- placeholder
1309
- }: FormFieldProps<T>) {
1310
- const { value, errors, disabled, touched } = useFormControl(control);
1311
- const showError = touched && errors.length > 0;
1385
+ function RegistrationPage() {
1386
+ const form = useMemo(
1387
+ () =>
1388
+ createForm<RegistrationForm>({
1389
+ form: {
1390
+ email: {
1391
+ value: '',
1392
+ component: Input,
1393
+ componentProps: { label: 'Email', type: 'email', placeholder: 'you@example.com' },
1394
+ },
1395
+ country: {
1396
+ value: 'ru',
1397
+ component: Select,
1398
+ componentProps: {
1399
+ label: 'Country',
1400
+ options: [
1401
+ { value: 'ru', label: 'Россия' },
1402
+ { value: 'by', label: 'Беларусь' },
1403
+ ],
1404
+ },
1405
+ },
1406
+ agree: {
1407
+ value: false,
1408
+ component: Checkbox,
1409
+ componentProps: { label: 'I agree to terms' },
1410
+ },
1411
+ } satisfies FormSchema<RegistrationForm>,
1412
+ }),
1413
+ []
1414
+ );
1312
1415
 
1313
1416
  return (
1314
- <div className="form-field">
1315
- {label && <label>{label}</label>}
1316
- <input
1317
- type={type}
1318
- value={value ?? ''}
1319
- onChange={(e) => {
1320
- const val = type === 'number'
1321
- ? Number(e.target.value) as T
1322
- : e.target.value as T;
1323
- control.setValue(val);
1324
- }}
1325
- onBlur={() => control.markAsTouched()}
1326
- disabled={disabled}
1327
- placeholder={placeholder}
1328
- className={showError ? 'error' : ''}
1329
- />
1330
- {showError && (
1331
- <span className="error-message">{errors[0].message}</span>
1332
- )}
1333
- </div>
1417
+ <form>
1418
+ <FormField control={form.email} testId="email" />
1419
+ <FormField control={form.country} testId="country" />
1420
+ <FormField control={form.agree} testId="agree" />
1421
+ <Button type="submit">Register</Button>
1422
+ </form>
1334
1423
  );
1335
1424
  }
1425
+ ```
1426
+
1427
+ `FormField` сам читает `componentProps.label`, `componentProps.placeholder`,
1428
+ `componentProps.options` через `useFormControl(...).componentProps` и применяет
1429
+ их к нужному `<input>`/`<select>`/etc. Error rendering, `pending` для async-валидаций,
1430
+ `data-testid` для e2e — всё из коробки.
1431
+
1432
+ ### Anti-patterns (не делай так)
1433
+
1434
+ ❌ **Свои field-компоненты с label-prop'ами в JSX**:
1336
1435
 
1337
- // Usage
1338
- <FormField control={form.email} label="Email" type="email" />
1339
- <FormField control={form.age} label="Age" type="number" />
1436
+ ```tsx
1437
+ // WRONG — дублирует логику FormField, ломает schema-driven архитектуру
1438
+ <Input control={form.email} label="Email" placeholder="..." />
1439
+ <Select control={form.country} options={[...]} />
1340
1440
  ```
1341
1441
 
1342
- ### FormField for Select
1442
+ **Передача компонент-пропсов через JSX вместо схемы**:
1343
1443
 
1344
1444
  ```tsx
1345
- interface SelectFieldProps<T extends string> {
1346
- control: FieldNode<T>;
1347
- label?: string;
1348
- options: Array<{ value: T; label: string }>;
1349
- }
1445
+ // WRONG нарушает single source of truth (схема)
1446
+ <FormField control={form.email} label="Email" />
1447
+ ```
1448
+
1449
+ ✅ Всё это в схеме:
1450
+
1451
+ ```ts
1452
+ { email: { component: Input, componentProps: { label: 'Email' } } }
1453
+ ```
1454
+
1455
+ ```tsx
1456
+ <FormField control={form.email} />
1457
+ ```
1458
+
1459
+ ### Advanced — кастомный input через `children` slot
1460
+
1461
+ Когда нужен низкоуровневый input, которого нет в ui-kit (маска, особый combobox):
1462
+
1463
+ ```tsx
1464
+ import { FormField } from '@reformer/ui-kit';
1465
+ import { InputMask } from 'react-input-mask';
1466
+
1467
+ <FormField control={form.phone} testId="phone">
1468
+ <InputMask mask="+7 (999) 999-99-99" />
1469
+ </FormField>;
1470
+ ```
1471
+
1472
+ `children` оборачивается в `CdkFormField.Control asChild` и получает все нужные
1473
+ props (`value`, `onChange`, `onBlur`, `aria-invalid`).
1474
+
1475
+ ### Advanced — write your own from scratch (rare)
1476
+
1477
+ Если ты не хочешь подключать `@reformer/ui-kit`, пиши свои компоненты на основе
1478
+ `useFormControl` — но **сохраняй schema-driven подход**: читай label/placeholder
1479
+ из `componentProps`, не из JSX-props.
1480
+
1481
+ ```tsx
1482
+ import type { FieldNode } from '@reformer/core';
1483
+ import { useFormControl } from '@reformer/core';
1350
1484
 
1351
- function SelectField<T extends string>({ control, label, options }: SelectFieldProps<T>) {
1352
- const { value, errors, disabled, touched } = useFormControl(control);
1485
+ type MyFormFieldProps<T> = { control: FieldNode<T> }; // ← ОДИН prop
1486
+
1487
+ function MyFormField<T>({ control }: MyFormFieldProps<T>) {
1488
+ const { value, errors, disabled, shouldShowError, componentProps } = useFormControl(control);
1489
+ // componentProps = { label, placeholder, type, options, ... } — из СХЕМЫ
1490
+ const cp = (componentProps ?? {}) as Record<string, unknown>;
1353
1491
 
1354
1492
  return (
1355
- <div className="form-field">
1356
- {label && <label>{label}</label>}
1357
- <select
1358
- value={value}
1359
- onChange={(e) => control.setValue(e.target.value as T)}
1493
+ <label>
1494
+ {cp.label && <span>{cp.label as string}</span>}
1495
+ <input
1496
+ type={(cp.type as string) ?? 'text'}
1497
+ value={(value ?? '') as string}
1498
+ placeholder={cp.placeholder as string | undefined}
1360
1499
  disabled={disabled}
1361
- >
1362
- {options.map((opt) => (
1363
- <option key={opt.value} value={opt.value}>
1364
- {opt.label}
1365
- </option>
1366
- ))}
1367
- </select>
1368
- {touched && errors[0] && <span className="error-message">{errors[0].message}</span>}
1369
- </div>
1500
+ onChange={(e) => (control.setValue as (v: unknown) => void)(e.target.value)}
1501
+ onBlur={() => control.markAsTouched()}
1502
+ />
1503
+ {shouldShowError && errors[0] && <span>{errors[0].message}</span>}
1504
+ </label>
1370
1505
  );
1371
1506
  }
1372
1507
  ```
1373
1508
 
1374
- ### Integration with UI Libraries
1509
+ Использование как у `FormField`:
1510
+
1511
+ ```tsx
1512
+ <MyFormField control={form.email} /> // ← без label-prop
1513
+ ```
1514
+
1515
+ ### Integration with UI libraries (shadcn etc.)
1516
+
1517
+ Если есть существующая design system — оборачивай её компоненты в один
1518
+ `MyFormField` (как выше) и используй один прop `control`. Не множь обёртки на
1519
+ тип input'а — пусть `componentProps.type` диспатчит внутри.
1375
1520
 
1376
1521
  ```tsx
1377
- // With shadcn/ui
1378
1522
  import { Input } from '@/components/ui/input';
1379
1523
  import { Label } from '@/components/ui/label';
1380
1524
 
1381
- function ShadcnFormField({ control, label }: FormFieldProps<string>) {
1382
- const { value, errors, disabled } = useFormControl(control);
1525
+ function ShadcnFormField({ control }: { control: FieldNode<string> }) {
1526
+ const { value, errors, disabled, componentProps } = useFormControl(control);
1527
+ const cp = (componentProps ?? {}) as Record<string, unknown>;
1383
1528
 
1384
1529
  return (
1385
1530
  <div className="space-y-2">
1386
- <Label>{label}</Label>
1387
- <Input value={value} onChange={(e) => control.setValue(e.target.value)} disabled={disabled} />
1531
+ {cp.label && <Label>{cp.label as string}</Label>}
1532
+ <Input
1533
+ value={(value ?? '') as string}
1534
+ onChange={(e) => control.setValue(e.target.value)}
1535
+ disabled={disabled}
1536
+ />
1388
1537
  {errors[0] && <p className="text-red-500">{errors[0].message}</p>}
1389
1538
  </div>
1390
1539
  );
@@ -3550,6 +3699,44 @@ const navRef = useRef<FormWizardHandle<MyForm>>(null);
3550
3699
  />
3551
3700
  ```
3552
3701
 
3702
+ ### Recipe 8 — enum-typed default values (union literal widening)
3703
+
3704
+ Когда default `value` поля — литерал union-type, TS widens его до `string`,
3705
+ ломая `FormSchema<T>` assignability:
3706
+
3707
+ ```typescript
3708
+ type Gender = 'male' | 'female';
3709
+
3710
+ // ❌ TS error: '"male"' is not assignable to 'Gender | null'
3711
+ const schema: FormSchema<{ gender: Gender }> = {
3712
+ gender: {
3713
+ value: 'male', // widens to string
3714
+ component: RadioGroup,
3715
+ componentProps: { options: [...] },
3716
+ },
3717
+ };
3718
+ ```
3719
+
3720
+ **Fix** — `satisfies FieldConfig<UnionType>` сужает без `as`-каста:
3721
+
3722
+ ```typescript
3723
+ gender: {
3724
+ value: 'male',
3725
+ component: RadioGroup,
3726
+ componentProps: { options: [...] },
3727
+ } satisfies FieldConfig<Gender>,
3728
+ ```
3729
+
3730
+ Альтернативы:
3731
+
3732
+ - `value: 'male' as Gender` — works, но `as`-каст. Избегай (см. anti-patterns).
3733
+ - `value: null` (если `Gender | null` допустим) — нет widening проблемы, но требует
3734
+ null-check в render/validation.
3735
+
3736
+ Применяется к любому union-полю: `loanType`, `employmentStatus`, `maritalStatus`,
3737
+ `propertyType`, и т.п. Симптом без этого fix'а — misleading TS2769
3738
+ "`'form' does not exist in FormSchema<...>`" (см. `05-common-mistakes.md`).
3739
+
3553
3740
  ### Anti-patterns to avoid
3554
3741
 
3555
3742
  - `import { type ValidationSchemaFn } from '@reformer/core/validators'` →
@@ -3562,7 +3749,165 @@ const navRef = useRef<FormWizardHandle<MyForm>>(null);
3562
3749
  - `as never` cast on `computeFrom` target — never necessary; if you reach
3563
3750
  for it, you have one of the issues above.
3564
3751
 
3565
- ## 67. API Reference
3752
+ ## 67. 31. ASYNC VALIDATOR WITH DEBOUNCE
3753
+
3754
+ Для проверок типа «уникальность email», «валидация INN через API», «проверка
3755
+ адреса по DaData» используй `asyncValidators` в `FieldConfig`:
3756
+
3757
+ ```ts
3758
+ import { type FieldConfig, type FormSchema, type AsyncValidatorFn } from '@reformer/core';
3759
+ import { required, email } from '@reformer/core/validators';
3760
+
3761
+ const checkEmailUnique: AsyncValidatorFn<string> = async (value) => {
3762
+ if (!value) return null; // empty = valid (sync `required` separately)
3763
+
3764
+ try {
3765
+ const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`);
3766
+ const { available } = (await res.json()) as { available: boolean };
3767
+ return available ? null : { code: 'email-taken', message: 'Email уже зарегистрирован' };
3768
+ } catch {
3769
+ return { code: 'check-failed', message: 'Не удалось проверить email' };
3770
+ }
3771
+ };
3772
+
3773
+ const schema: FormSchema<{ email: string }> = {
3774
+ email: {
3775
+ value: '',
3776
+ component: Input,
3777
+ validators: [required(), email()], // sync first
3778
+ asyncValidators: [checkEmailUnique], // async after sync passed
3779
+ debounce: 500, // debounce input → API (поле `debounce`, не `asyncDebounceMs`)
3780
+ },
3781
+ };
3782
+ ```
3783
+
3784
+ ### Lifecycle
3785
+
3786
+ 1. На каждое `setValue` запускается sync `validators` (`required`, `email`)
3787
+ 2. Если sync passed — стартует таймер `debounce`
3788
+ 3. По истечении debounce и стабильном value — вызывается каждый `asyncValidator`
3789
+ 4. Во время async-проверки `useFormControl(...).pending === true` — UI может показать спиннер
3790
+ 5. Результат записывается в `errors` поля
3791
+
3792
+ ### UI integration
3793
+
3794
+ `FormField` из `@reformer/ui-kit` автоматически рендерит `<span>Проверка...</span>`
3795
+ когда `pending === true`. Если рендеришь сам — используй:
3796
+
3797
+ ```tsx
3798
+ const { pending, errors } = useFormControl(form.email);
3799
+ return pending ? <Spinner /> : errors.length ? <Error errors={errors} /> : null;
3800
+ ```
3801
+
3802
+ ### Common patterns
3803
+
3804
+ - **Debounce 500ms** — баланс между UX и API rate-limit. Для дорогих API увеличивай
3805
+ до 1000-2000ms.
3806
+ - **Cancellation** — если `setValue` приходит во время выполнения предыдущего async,
3807
+ ReFormer автоматически отбрасывает результат старого вызова. Не нужно вручную
3808
+ abort'ить fetch (но reasonable practice — поддержать `AbortSignal` через
3809
+ `ctx.signal` если есть).
3810
+ - **Cross-field async** — для валидаций типа «дата начала > дата окончания» используй
3811
+ sync `validators` с `applyWhen`, а не `asyncValidators`.
3812
+
3813
+ ### See also
3814
+
3815
+ - [11-async-watchfield.md](11-async-watchfield.md) — async для `watchField`, не валидаторов
3816
+ - [29-async-preload.md](29-async-preload.md) — async preload данных в init формы
3817
+ - API: `validateAsync(field): Promise<boolean>` — manual trigger, обычно не нужен
3818
+
3819
+ ## 68. 32. ASYNC OPTIONS LOADING
3820
+
3821
+ Для динамической подгрузки опций dropdown'а в зависимости от значения другого поля
3822
+ (например: `region` → `city options`, `carBrand` → `carModel options`) — используй
3823
+ `watchField` + `updateComponentProps({ options })`:
3824
+
3825
+ ```ts
3826
+ import { watchField } from '@reformer/core/behaviors';
3827
+ import type { BehaviorSchemaFn } from '@reformer/core';
3828
+
3829
+ type CityOption = { value: string; label: string };
3830
+
3831
+ async function fetchCitiesByRegion(region: string): Promise<CityOption[]> {
3832
+ const res = await fetch(`/api/cities?region=${encodeURIComponent(region)}`);
3833
+ return res.json();
3834
+ }
3835
+
3836
+ const behavior: BehaviorSchemaFn<MyForm> = (path) => {
3837
+ watchField(
3838
+ path.registrationAddress.region,
3839
+ async (region, ctx) => {
3840
+ if (!region) {
3841
+ ctx.form.registrationAddress.city.updateComponentProps({ options: [] });
3842
+ ctx.form.registrationAddress.city.setValue('');
3843
+ return;
3844
+ }
3845
+
3846
+ // Set loading state (optional — UI показывает spinner)
3847
+ ctx.form.registrationAddress.city.updateComponentProps({
3848
+ loading: true,
3849
+ options: [],
3850
+ });
3851
+
3852
+ try {
3853
+ const options = await fetchCitiesByRegion(region);
3854
+ ctx.form.registrationAddress.city.updateComponentProps({
3855
+ loading: false,
3856
+ options,
3857
+ });
3858
+ } catch {
3859
+ ctx.form.registrationAddress.city.updateComponentProps({
3860
+ loading: false,
3861
+ options: [],
3862
+ });
3863
+ // optionally — set error на поле через setError
3864
+ }
3865
+ },
3866
+ { debounce: 300 }
3867
+ );
3868
+ };
3869
+ ```
3870
+
3871
+ ### Lifecycle
3872
+
3873
+ 1. `watchField` подписан на изменения `path.region`
3874
+ 2. На каждое изменение — debounce 300ms (чтобы не fetch на каждое нажатие клавиши)
3875
+ 3. После debounce — async `fetchCitiesByRegion(region)`
3876
+ 4. Результат — `updateComponentProps({ options })` на target поле
3877
+ 5. UI (Select / Combobox) автоматически обновится через signal на componentProps
3878
+
3879
+ ### Common patterns
3880
+
3881
+ - **Debounce 300-500ms** — баланс между UX и rate-limit
3882
+ - **Reset target value** — при смене source очисти `setValue('')` чтобы не остался stale выбор
3883
+ - **Loading state** — `componentProps.loading: true` пока fetch идёт (UI компонент должен это поддержать)
3884
+ - **Cancellation** — ReFormer **не** отменяет старый fetch автоматически. Если предыдущий fetch ещё идёт, а пришло новое значение — новый запустится параллельно. Может приводить к race. Workaround: использовать `AbortController` через closure:
3885
+ ```ts
3886
+ let abortController: AbortController | null = null;
3887
+ watchField(path.region, async (region, ctx) => {
3888
+ abortController?.abort();
3889
+ abortController = new AbortController();
3890
+ try {
3891
+ const opts = await fetchCities(region, { signal: abortController.signal });
3892
+ ctx.form.city.updateComponentProps({ options: opts });
3893
+ } catch (e) {
3894
+ if ((e as Error).name === 'AbortError') return;
3895
+ throw e;
3896
+ }
3897
+ });
3898
+ ```
3899
+
3900
+ ### Initial load (preload at form mount)
3901
+
3902
+ Для preload опций при инициализации формы — используй `async-preload` recipe (см. [29-async-preload.md](29-async-preload.md)). Не `watchField` — он не triggers на init.
3903
+
3904
+ ### See also
3905
+
3906
+ - [11-async-watchfield.md](11-async-watchfield.md) — общий паттерн async в `watchField`
3907
+ - [29-async-preload.md](29-async-preload.md) — preload данных при инициализации
3908
+ - [31-async-validator-debounce.md](31-async-validator-debounce.md) — для async **валидации** (не options)
3909
+
3910
+ ## 69. API Reference
3566
3911
 
3567
3912
  _Auto-generated from JSDoc on public exports._
3568
3913
 
@@ -4640,7 +4985,7 @@ _Source: src/core/behavior/behaviors/enable-when.ts_
4640
4985
 
4641
4986
  **Signature:**
4642
4987
  ```typescript
4643
- export function email<TForm, TField extends string | undefined = string>(
4988
+ export function email<TForm, TField extends string | null | undefined = string>(
4644
4989
  fieldPath: FieldPathNode<TForm, TField> | undefined,
4645
4990
  options?: ValidateOptions
4646
4991
  ): void
@@ -6947,7 +7292,7 @@ _Source: src/core/utils/field-path-navigator.ts_
6947
7292
 
6948
7293
  **Signature:**
6949
7294
  ```typescript
6950
- export function pattern<TForm, TField extends string | undefined = string>(
7295
+ export function pattern<TForm, TField extends string | null | undefined = string>(
6951
7296
  fieldPath: FieldPathNode<TForm, TField> | undefined,
6952
7297
  regex: RegExp,
6953
7298
  options?: ValidateOptions
@@ -6994,7 +7339,7 @@ _Source: src/core/validation/validators/pattern.ts_
6994
7339
 
6995
7340
  **Signature:**
6996
7341
  ```typescript
6997
- export function phone<TForm, TField extends string | undefined = string>(
7342
+ export function phone<TForm, TField extends string | null | undefined = string>(
6998
7343
  fieldPath: FieldPathNode<TForm, TField> | undefined,
6999
7344
  options?: ValidateOptions & {
7000
7345
  /** Формат телефона */
@@ -7953,7 +8298,7 @@ _Source: src/core/types/index.ts_
7953
8298
 
7954
8299
  **Signature:**
7955
8300
  ```typescript
7956
- export function url<TForm, TField extends string | undefined = string>(
8301
+ export function url<TForm, TField extends string | null | undefined = string>(
7957
8302
  fieldPath: FieldPathNode<TForm, TField> | undefined,
7958
8303
  options?: ValidateOptions & {
7959
8304
  /** Требовать наличие протокола (http:// или https://) */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@reformer/core",
3
- "version": "4.0.0",
3
+ "version": "5.0.2",
4
4
  "description": "Reactive form state management library for React with signals-based architecture",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",