@reformer/core 10.2.0 → 11.0.0-beta.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.
Files changed (63) hide show
  1. package/dist/behaviors.js +134 -134
  2. package/dist/form/form-submitter.d.ts +0 -4
  3. package/dist/form/hooks/types.d.ts +3 -3
  4. package/dist/form/index.d.ts +0 -3
  5. package/dist/form/nodes/array-node.d.ts +0 -84
  6. package/dist/form/nodes/form-node.d.ts +0 -14
  7. package/dist/form/nodes/group-node.d.ts +0 -4
  8. package/dist/form/status-machine.d.ts +0 -38
  9. package/dist/form/types/contracts.d.ts +0 -6
  10. package/dist/form/types/deep-schema.d.ts +0 -9
  11. package/dist/form/types/index.d.ts +4 -38
  12. package/dist/form/types/schema-node.d.ts +13 -16
  13. package/dist/form/types/validation-schema.d.ts +0 -25
  14. package/dist/form/unique-id.d.ts +3 -11
  15. package/dist/form/validators.d.ts +8 -3
  16. package/dist/form-model-DhW2HZOB.js +323 -0
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +448 -636
  19. package/dist/runtime-token.d.ts +23 -0
  20. package/dist/state/form-model.d.ts +14 -0
  21. package/dist/state/index.d.ts +2 -1
  22. package/dist/state/types.d.ts +39 -11
  23. package/dist/state.js +18 -18
  24. package/dist/validation.js +1 -1
  25. package/dist/validators.d.ts +0 -4
  26. package/dist/validators.js +88 -122
  27. package/docs/llms/01-api-reference.md +63 -0
  28. package/docs/llms/02-quick-start.md +143 -0
  29. package/docs/llms/03-api-signatures.md +273 -0
  30. package/docs/llms/04-common-patterns.md +202 -0
  31. package/docs/llms/05-common-mistakes.md +167 -0
  32. package/docs/llms/06-troubleshooting.md +11 -0
  33. package/docs/llms/07-complete-import.md +99 -0
  34. package/docs/llms/08-form-types.md +47 -0
  35. package/docs/llms/09-formschema.md +143 -0
  36. package/docs/llms/10-arrays.md +161 -0
  37. package/docs/llms/11-async-watchfield.md +53 -0
  38. package/docs/llms/12-array-cleanup.md +31 -0
  39. package/docs/llms/13-multi-step.md +91 -0
  40. package/docs/llms/14-extended-mistakes.md +98 -0
  41. package/docs/llms/15-project-structure.md +125 -0
  42. package/docs/llms/16-ui-components.md +189 -0
  43. package/docs/llms/17-nonexistent-api.md +93 -0
  44. package/docs/llms/18-conditional-fields.md +183 -0
  45. package/docs/llms/19-reading-values.md +88 -0
  46. package/docs/llms/20-compute-vs-watch.md +147 -0
  47. package/docs/llms/21-array-operations.md +113 -0
  48. package/docs/llms/22-cycle-detection.md +86 -0
  49. package/docs/llms/23-copy-from.md +141 -0
  50. package/docs/llms/24-sync-fields.md +114 -0
  51. package/docs/llms/25-reset-when.md +115 -0
  52. package/docs/llms/26-transform-value.md +136 -0
  53. package/docs/llms/27-revalidate-when.md +132 -0
  54. package/docs/llms/28-submit-and-reset.md +259 -0
  55. package/docs/llms/29-async-preload.md +193 -0
  56. package/docs/llms/30-type-safety-recipes.md +159 -0
  57. package/docs/llms/31-async-validator-debounce.md +135 -0
  58. package/docs/llms/32-async-options-loading.md +108 -0
  59. package/docs/llms/33-validation-strategy.md +193 -0
  60. package/llms.txt +219 -1533
  61. package/package.json +33 -10
  62. package/dist/derived-registry-CluZCQ7U.js +0 -16
  63. package/dist/form-model-DYCzGGKF.js +0 -286
@@ -0,0 +1,159 @@
1
+ ## 30. TYPE-SAFETY RECIPES
2
+
3
+ Идиоматичные паттерны, которые держат сгенерированный код без `any` и `as`-кастов под M1.
4
+
5
+ ### Recipe 1 — Imports (root cause prevention)
6
+
7
+ - Модель/форма/хуки/типы/**примитивы behaviors** — из `@reformer/core`.
8
+ - Схема валидации (операторы + раннер `validateModel`) — из `@reformer/core/validation`.
9
+ - Чистые фабрики валидаторов — из `@reformer/core/validators`.
10
+ - Декларативный DSL (`defineFormBehavior` + операторы) — из `@reformer/core/behaviors`.
11
+
12
+ ```typescript
13
+ import {
14
+ createModel,
15
+ createForm,
16
+ type FormModel,
17
+ type FormProxy,
18
+ type ModelSignals,
19
+ } from '@reformer/core';
20
+ import {
21
+ defineValidationSchema,
22
+ validate,
23
+ cross,
24
+ validateModel,
25
+ type Rule,
26
+ } from '@reformer/core/validation';
27
+ import { required, min, max, email } from '@reformer/core/validators';
28
+ import { defineFormBehavior, compute, enableWhen, onChange } from '@reformer/core/behaviors';
29
+ ```
30
+
31
+ > **watchField — из `@reformer/core`** (примитив), НЕ из `@reformer/core/behaviors` (там `onChange`).
32
+
33
+ ### Recipe 2 — Form-shape types as `type`, not `interface`
34
+
35
+ `Record<string, FormValue>` требует index signature. У `interface` её нет неявно; у `type` —
36
+ структурно. Объявляй через `type`-alias всё, что попадает в `FormProxy<T>`/`ArrayNode<T>`:
37
+ корневую форму, вложенные группы, типы элементов массива.
38
+
39
+ ```typescript
40
+ export type PropertyItem = {
41
+ type: 'apartment' | 'house' | 'car';
42
+ description: string;
43
+ estimatedValue: number;
44
+ };
45
+
46
+ export type CreditApplicationForm = {
47
+ loanAmount: number | null;
48
+ properties: PropertyItem[];
49
+ // ...
50
+ };
51
+ ```
52
+
53
+ ### Recipe 3 — Схема привязана к сигналам модели
54
+
55
+ `value` поля — это сигнал модели (`model.$.field`), а не литерал. Тип поля выводится из сигнала.
56
+ Layout-схема БЕЗ валидаторов — правила живут в отдельной validation-схеме.
57
+
58
+ ```typescript
59
+ const model = createModel<CreditApplicationForm>(initial);
60
+
61
+ const schema = {
62
+ loanAmount: { value: model.$.loanAmount, component: Input },
63
+ // вложенная группа — builder, принимающий ModelSignals<Sub>
64
+ personalData: personalDataNodes(model.$.personalData),
65
+ // массив — { array, item }
66
+ properties: { array: model.properties, item: propertyItem },
67
+ };
68
+
69
+ // правила — отдельно; типы полей выводятся из сигналов
70
+ const validation = defineValidationSchema<CreditApplicationForm>(({ model }) => {
71
+ validate(model.$.loanAmount, [required(), min(50000)]);
72
+ });
73
+ ```
74
+
75
+ ### Recipe 4 — Cross-field валидаторы: `cross` над типизированным снапшотом
76
+
77
+ Правило — обычная функция `(f: Root) => ValidationError | null` над снапшотом `model.get()`.
78
+ Соседние поля читаются без `as`; ошибка вешается на поле-носитель `sig`:
79
+
80
+ ```typescript
81
+ import type { ValidationError } from '@reformer/core';
82
+
83
+ const initialPaymentVsProperty = (f: CreditApplicationForm): ValidationError | null =>
84
+ f.initialPayment && f.propertyValue && f.initialPayment > f.propertyValue
85
+ ? { code: 'tooHigh', message: 'Взнос не может превышать стоимость' }
86
+ : null;
87
+
88
+ // внутри defineValidationSchema<CreditApplicationForm>(({ model }) => { ... }):
89
+ cross(model.$.initialPayment, initialPaymentVsProperty);
90
+ ```
91
+
92
+ ### Recipe 5 — `compute` читает модель напрямую (без аннотаций)
93
+
94
+ `compute(target, () => …)` читает value-модель (`model.field`) — типы полей выводятся из типа
95
+ модели, `as`-касты не нужны:
96
+
97
+ ```typescript
98
+ compute(model.$.monthlyPayment, () =>
99
+ annuityMonthly(model.loanAmount ?? 0, model.loanTerm ?? 0, model.interestRate ?? 0)
100
+ );
101
+
102
+ // nested reads — тоже напрямую
103
+ compute(model.$.fullName, () =>
104
+ [model.personalData.firstName, model.personalData.lastName].filter(Boolean).join(' ')
105
+ );
106
+ ```
107
+
108
+ ### Recipe 6 — `null` vs `undefined` для опциональных полей
109
+
110
+ Оба работают. `null` — конвенция «пользователь очистил поле». Встроенные валидаторы
111
+ (`min`, `max`, `minLength`, `maxLength`, `minDate`, `maxDate`, `minAge`, `maxAge`) пропускают
112
+ пустые значения — guard `if (value != null)` не нужен.
113
+
114
+ ```typescript
115
+ export type CreditForm = {
116
+ loanAmount: number | null; // min(model.$.loanAmount, 50000) — ок
117
+ loanPurpose: string | null; // minLength — ок
118
+ birthDate: string | null; // minAge — ок
119
+ };
120
+ ```
121
+
122
+ ### Recipe 7 — Нода поля для кастомных компонентов
123
+
124
+ `useFormControl(control)` типизируется по `FieldNode<T>`. В props компонента используй `FieldNode<T>`:
125
+
126
+ ```typescript
127
+ import type { FieldNode } from '@reformer/core';
128
+
129
+ type MyFieldProps<T> = { control: FieldNode<T> };
130
+ function MyField<T>({ control }: MyFieldProps<T>) {
131
+ const { value, errors, disabled } = useFormControl(control);
132
+ // ...
133
+ }
134
+ ```
135
+
136
+ ### Recipe 8 — Вынос правил в именованные функции
137
+
138
+ Field-правила — именованные `Rule<T>`, validation-схема остаётся плоской:
139
+
140
+ ```typescript
141
+ import type { Rule } from '@reformer/core/validation';
142
+
143
+ const validateAdultAge: Rule<string | null> = (value) => {
144
+ if (!value) return null;
145
+ const age = new Date().getFullYear() - new Date(value).getFullYear();
146
+ return age < 18 ? { code: 'tooYoung', message: 'Минимум 18 лет' } : null;
147
+ };
148
+
149
+ // внутри defineValidationSchema<MyForm>(({ model }) => { ... }):
150
+ validate(model.$.birthDate, [validateAdultAge]);
151
+ ```
152
+
153
+ ### Anti-patterns to avoid
154
+
155
+ - `import { computeFrom } from '@reformer/core/behaviors'` для примитива, вызываемого вне
156
+ `defineFormBehavior` → примитив живёт в `@reformer/core` (возвращает cleanup).
157
+ - `interface MyForm { ... }` для form-shape → см. Recipe 2.
158
+ - `as`-касты значений полей внутри `compute` → читай `model.field` напрямую (Recipe 5).
159
+ - строковые пути / `(form) => ...` в behaviors → это удалённый API, используй сигналы (`model.$.x`).
@@ -0,0 +1,135 @@
1
+ ## 31. ASYNC VALIDATOR
2
+
3
+ Для проверок типа «уникальность email», «валидация ИНН через API», «проверка адреса» —
4
+ async-правило это `AsyncRule<T>` = `(value, { signal }) => Promise<ValidationError | null>`.
5
+ Оно регистрируется оператором `validateAsync(sig, [asyncRules])` внутри схемы валидации
6
+ (`@reformer/core/validation`) и исполняется внешним раннером `validateModel` (async-правила
7
+ прогоняются параллельно через `Promise.all`, раннер их дожидается).
8
+
9
+ Слои разделены: layout (`createForm`-схема / JSON) НЕ несёт валидаторов — правила живут в
10
+ отдельной функции-схеме `defineValidationSchema<T>(({ model }) => …)`.
11
+
12
+ ```ts
13
+ import { createModel } from '@reformer/core';
14
+ import {
15
+ validate,
16
+ validateAsync,
17
+ defineValidationSchema,
18
+ validateModel,
19
+ type AsyncRule,
20
+ } from '@reformer/core/validation';
21
+ import { required, email } from '@reformer/core/validators';
22
+
23
+ // async-правило: (value, { signal }) => Promise<ValidationError | null>.
24
+ // `signal` — AbortSignal устаревшего прогона: прокинь его в fetch, чтобы отменить in-flight.
25
+ const checkEmailUnique: AsyncRule<string> = async (value, { signal }) => {
26
+ if (!value) return null; // пусто = валидно (sync `required` отдельно)
27
+ try {
28
+ const res = await fetch(`/api/check-email?email=${encodeURIComponent(value)}`, { signal });
29
+ const { available } = (await res.json()) as { available: boolean };
30
+ return available ? null : { code: 'email-taken', message: 'Email уже зарегистрирован' };
31
+ } catch {
32
+ return null; // сетевой сбой/отмена НЕ блокирует submit — возвращаем null, а не ошибку
33
+ }
34
+ };
35
+
36
+ const model = createModel<{ email: string }>({ email: '' });
37
+
38
+ // Схема — обычная функция над моделью; sync и async — разными операторами на одном сигнале.
39
+ const schema = defineValidationSchema<{ email: string }>(({ model }) => {
40
+ validate(model.$.email, [required(), email()]); // sync-фабрики
41
+ validateAsync(model.$.email, [checkEmailUnique]); // async-правило
42
+ });
43
+
44
+ // Прогон по требованию (submit / шаг):
45
+ const ok = await validateModel(model, schema); // Promise<boolean>
46
+ ```
47
+
48
+ ### Как это исполняется
49
+
50
+ 1. `validateModel(model, schema)` открывает ambient-окно и **синхронно** прогоняет схему:
51
+ `validate`/`validateAsync`/`cross` регистрируют правила своих полей.
52
+ 2. Sync-правила выполняются сразу; async-правила из `validateAsync` собираются и после закрытия
53
+ ambient-окна дожидаются параллельно (`Promise.all`) с прокинутым `AbortSignal`.
54
+ 3. Ошибки роутятся в ноды формы (`getNodeForSignal(sig).setErrors(...)`), UI подсвечивает поле;
55
+ поля, ставшие валидными, гасятся (`setErrors([])`).
56
+ 4. Возвращает `Promise<boolean>` — `true`, если нет блокирующих ошибок (`severity:'warning'`
57
+ не блокирует). Устаревший (отменённый) прогон возвращает `false` — ему нельзя доверять для submit.
58
+
59
+ Синхронного варианта у нового контракта нет: раннер один — `validateModel`, и он всегда `async`.
60
+
61
+ ### Sync и async — два оператора
62
+
63
+ Разделение sync/async делается не полем схемы, а разными операторами на одном сигнале:
64
+ `validate(sig, [syncRules])` и `validateAsync(sig, [asyncRules])`. Раннер прогоняет sync-правила
65
+ инлайн, а async — дожидается. Держи async-правило дешёвым: ранний `if (!value) return null`
66
+ пропускает сетевой вызов, пока `required()`/формат ещё не пройдены.
67
+
68
+ ```ts
69
+ validate(model.$.inn, [required(), pattern(/^\d{12}$/)]); // формат — sync, мгновенно
70
+ validateAsync(model.$.inn, [checkInnInRegistry]); // обращение к API — отдельным оператором
71
+ ```
72
+
73
+ ### UI integration
74
+
75
+ `validateModel(...)` возвращает `Promise<boolean>` — индикатор проверки держи вокруг этого
76
+ `await`. Раннер схемы **не** выставляет per-field `pending` на ноде (он роутит только ошибки через
77
+ `setErrors`), поэтому спиннер async-валидации — это собственный флаг (обычно form-level, как
78
+ `ui.pending` в submit-флоу, или локальный state компонента):
79
+
80
+ ```tsx
81
+ const [checking, setChecking] = useState(false);
82
+
83
+ const runValidation = async (): Promise<boolean> => {
84
+ setChecking(true);
85
+ try {
86
+ return await validateModel(model, schema);
87
+ } finally {
88
+ setChecking(false);
89
+ }
90
+ };
91
+
92
+ return checking ? <Spinner /> : errors.length ? <Error errors={errors} /> : null;
93
+ ```
94
+
95
+ ### Debounce и отмена
96
+
97
+ - **Отмена устаревших — раннером, бесплатно.** Быстрый повторный `validateModel(model, schema)`
98
+ той же пары `(model, schema)` отменяет предыдущий in-flight прогон через `AbortController`;
99
+ его `AbortSignal` прокинут в `validateAsync`-правила, поэтому `fetch(url, { signal })` рвётся
100
+ сам. Ручной debounce для КОРРЕКТНОСТИ не нужен — держи схему стабильным `const`
101
+ (`defineValidationSchema`), иначе отмена не сматчит прогоны по идентичности.
102
+ - Валидация запускается on-demand (submit / шаг / через `revalidateWhen`), а не на каждый
103
+ keystroke — отдельный `debounce` в правиле обычно не нужен.
104
+ - Если нужно дебаунсить дорогой async-валидатор относительно частых изменений — триггерь прогон
105
+ из поведения через `onChange`, который даёт debounce из коробки:
106
+
107
+ ```ts
108
+ import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
109
+ import { validateModel } from '@reformer/core/validation';
110
+
111
+ export const behavior = defineFormBehavior<{ username: string }>(({ model }) => {
112
+ onChange(model.$.username, () => void validateModel(model, schema), { debounce: 300 });
113
+ });
114
+ ```
115
+
116
+ Либо оборачивай в свой debounce колбэк `revalidateWhen([...], () => void validateModel(...))`.
117
+ - **Async cross-field** — инлайн `validateAsync`-правило, замыкающее `model` и читающее снапшот
118
+ соседей `model.get()` до первого `await` (у `AsyncRule` нет `root`, только `value` + `signal`;
119
+ `cross(...)` — синхронный):
120
+
121
+ ```ts
122
+ validateAsync(model.$.email, [
123
+ async (email, { signal }) => {
124
+ const { username } = model.get(); // снапшот соседних полей до await
125
+ const res = await fetch(`/api/check?email=${email}&user=${username}`, { signal });
126
+ return (await res.json()).ok ? null : { code: 'conflict', message: 'Пара занята' };
127
+ },
128
+ ]);
129
+ ```
130
+
131
+ ### See also
132
+
133
+ - [27-revalidate-when.md](27-revalidate-when.md) — перезапуск `validateModel` по триггерам
134
+ - [29-async-preload.md](29-async-preload.md) — async preload данных при init формы
135
+ - [32-async-options-loading.md](32-async-options-loading.md) — `onChange` с debounce + AbortSignal
@@ -0,0 +1,108 @@
1
+ ## 32. ASYNC OPTIONS LOADING
2
+
3
+ Для динамической подгрузки опций dropdown'а по значению другого поля (`region` → `city options`,
4
+ `carBrand` → `carModel options`) — используй `onChange` из `@reformer/core/behaviors` +
5
+ `form.field.updateComponentProps({ options })`. `onChange` даёт debounce и AbortSignal из коробки.
6
+
7
+ ```ts
8
+ import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
9
+
10
+ type CityOption = { value: string; label: string };
11
+
12
+ async function fetchCitiesByRegion(region: string, opts?: { signal?: AbortSignal }): Promise<CityOption[]> {
13
+ const res = await fetch(`/api/cities?region=${encodeURIComponent(region)}`, { signal: opts?.signal });
14
+ return res.json();
15
+ }
16
+
17
+ export const behavior = defineFormBehavior<MyForm>(({ model, form }) => {
18
+ onChange(
19
+ model.$.registrationAddress.region,
20
+ async (region, { signal }) => {
21
+ // сбросить зависимое поле при смене source
22
+ model.registrationAddress.city = '';
23
+
24
+ if (!region) {
25
+ form.registrationAddress.city.updateComponentProps({ options: [] });
26
+ return;
27
+ }
28
+
29
+ // (опционально) loading-состояние
30
+ form.registrationAddress.city.updateComponentProps({ loading: true, options: [] });
31
+
32
+ try {
33
+ const options = await fetchCitiesByRegion(region, { signal }); // отмена устаревших
34
+ form.registrationAddress.city.updateComponentProps({ loading: false, options });
35
+ } catch (e) {
36
+ if ((e as Error).name === 'AbortError') return; // устаревший запрос — молча выходим
37
+ form.registrationAddress.city.updateComponentProps({ loading: false, options: [] });
38
+ }
39
+ },
40
+ { debounce: 300 }
41
+ );
42
+ });
43
+ ```
44
+
45
+ ### Lifecycle
46
+
47
+ 1. `onChange` подписан на изменения `model.$.region`.
48
+ 2. Debounce 300 мс (не fetch на каждое нажатие клавиши).
49
+ 3. Async `fetchCitiesByRegion(region, { signal })` — `signal` отменяет устаревший запрос при
50
+ следующей смене значения (защита от race).
51
+ 4. Результат — `updateComponentProps({ options })` на target-поле.
52
+ 5. UI (Select / Combobox) обновляется через сигнал `componentProps`.
53
+
54
+ ### Переиспользуемый оператор
55
+
56
+ Как в монорепо — собираешь свой оператор поверх `onChange`:
57
+
58
+ ```ts
59
+ import { onChange } from '@reformer/core/behaviors';
60
+ import type { ReadonlySignal } from '@reformer/core/behaviors';
61
+
62
+ function loadOptionsOn<TValue, TOption>(
63
+ source: ReadonlySignal<TValue>,
64
+ target: { updateComponentProps(p: Record<string, unknown>): void; reset?: () => void },
65
+ fetcher: (value: TValue, opts?: { signal?: AbortSignal }) => Promise<TOption[]>,
66
+ options: { debounce?: number; resetTarget?: boolean } = {}
67
+ ): void {
68
+ const { debounce = 300, resetTarget = false } = options;
69
+ onChange(
70
+ source,
71
+ async (value, { signal }) => {
72
+ if (resetTarget) target.reset?.();
73
+ if (!value) { target.updateComponentProps({ options: [] }); return; }
74
+ try {
75
+ const data = await fetcher(value, { signal });
76
+ target.updateComponentProps({ options: data });
77
+ } catch {
78
+ target.updateComponentProps({ options: [] });
79
+ }
80
+ },
81
+ { debounce }
82
+ );
83
+ }
84
+
85
+ // Usage:
86
+ loadOptionsOn(model.$.carBrand, form.carModel, fetchCarModels, { resetTarget: true });
87
+ ```
88
+
89
+ ### Common patterns
90
+
91
+ - **Debounce 300–500 мс** — баланс UX и rate-limit.
92
+ - **Reset target value** — при смене source очисти зависимое поле (`model.city = ''`), чтобы не
93
+ остался устаревший выбор.
94
+ - **Loading state** — `updateComponentProps({ loading: true })` пока идёт fetch (компонент должен поддержать).
95
+ - **Cancellation** — `onChange` даёт `{ signal }` (AbortSignal); передавай его в `fetch` и
96
+ игнорируй `AbortError`.
97
+
98
+ ### Initial load (preload at form mount)
99
+
100
+ Для загрузки опций при инициализации формы — external hook + `model.set` + `updateComponentProps`
101
+ (см. [29-async-preload.md](29-async-preload.md)). `onChange` не срабатывает на init (по умолчанию
102
+ `immediate: false`); для запуска сразу передай `{ immediate: true }`.
103
+
104
+ ### See also
105
+
106
+ - [11-async-watchfield.md](11-async-watchfield.md) — общий паттерн `onChange`
107
+ - [29-async-preload.md](29-async-preload.md) — preload данных при инициализации
108
+ - [31-async-validator-debounce.md](31-async-validator-debounce.md) — async **валидация** (не options)
@@ -0,0 +1,193 @@
1
+ # useFormValidation — Единый выбор стратегии валидации
2
+
3
+ ## Purpose
4
+
5
+ Одна декларативная точка выбора «когда прогонять схему валидации» вместо ручной разводки
6
+ `markAsTouched` + `validateModel` + `revalidateWhen` + `useState(pending)`. Аддитивный слой над
7
+ функциональной схемой (`@reformer/core/validation`): переиспользует `validateModel` как
8
+ ЕДИНСТВЕННЫЙ движок (роутинг ошибок в ноды, отмена устаревших прогонов, дедуп по `(model, schema)`),
9
+ второго движка не заводит. Node-level `updateOn` / `debounce` на самой ноде поля (legacy-триггеры)
10
+ этот API НЕ трогает.
11
+
12
+ Стратегия определяет только момент запуска и раскрытие ошибок — сами правила остаются в отдельной
13
+ `defineValidationSchema<T>(({ model }) => …)`.
14
+
15
+ ## API
16
+
17
+ Основной способ — React-хук `useFormValidation` из `@reformer/core` (рядом с `useFormControl`).
18
+ Под ним — headless-фабрика `createFormValidation` из `@reformer/core/validation` (для SSR / тестов /
19
+ не-React потребителей).
20
+
21
+ ```typescript
22
+ // React (@reformer/core)
23
+ function useFormValidation<T>(args: {
24
+ model: FormModel<T>;
25
+ schema: ValidationSchema<T>; // СТАБИЛЬНАЯ ссылка (module-level const / useMemo)
26
+ strategy?: ValidationStrategyKind; // default 'submit'
27
+ debounce?: number; // мс, для live-фаз (change / live-часть afterFirstSubmit)
28
+ liveAfterSubmit?: 'change' | 'blur'; // default 'change'
29
+ }): { submit: () => Promise<boolean>; validate: () => Promise<boolean>; isValidating: boolean };
30
+
31
+ // headless (@reformer/core/validation)
32
+ type ValidationStrategyKind = 'submit' | 'blur' | 'change' | 'afterFirstSubmit';
33
+ interface ValidationStrategyOptions {
34
+ strategy?: ValidationStrategyKind;
35
+ debounce?: number;
36
+ liveAfterSubmit?: 'change' | 'blur';
37
+ }
38
+ interface FormValidationController {
39
+ validate(): Promise<boolean>; // полный прогон, touch:true (раскрыть ВСЕ ошибки); переводит afterFirstSubmit в live-фазу
40
+ start(): () => void; // армит реактивные подписки (ТОЛЬКО на клиенте) → dispose
41
+ dispose(): void;
42
+ readonly isValidating: boolean;
43
+ readonly validating: ReadonlySignal<boolean>;
44
+ }
45
+ function createFormValidation<T>(
46
+ model: FormModel<T>,
47
+ schema: ValidationSchema<T>,
48
+ options?: ValidationStrategyOptions,
49
+ ): FormValidationController;
50
+ ```
51
+
52
+ - `useFormValidation` мемоизирует контроллер по `[model, schema, strategy, debounce, liveAfterSubmit]`,
53
+ армит стратегию в `useEffect` (SSR-safe), а `submit()` = `controller.validate()`.
54
+ - `createFormValidation` ЧИСТА до `start()` (никаких подписок) → SSR-safe; `start()` возвращает
55
+ `dispose` и арминует триггеры только на клиенте.
56
+
57
+ ### Стратегии
58
+
59
+ | strategy | когда прогон | touch | раскрытие ошибок |
60
+ | --- | --- | --- | --- |
61
+ | `submit` (default) | только `validate()` / `submit()` | `true` | всё на submit |
62
+ | `blur` | смена `touched` поля (потеря фокуса) | `false` | только сблюренные (по `touched`) |
63
+ | `change` | на каждый ввод (+ `debounce`) | `false` | только редактированные (по `dirty`) |
64
+ | `afterFirstSubmit` | тихо до 1-го submit, затем live (`liveAfterSubmit: 'change'` \| `'blur'`, default `'change'`) | `true` один раз на 1-м submit, дальше `false` | тихо → раскрыть всё на submit → далее live |
65
+
66
+ ## Examples
67
+
68
+ ### Простая форма — afterFirstSubmit + debounce
69
+
70
+ Тихо до первой отправки, `submit()` раскрывает все ошибки и переводит форму в живую фазу
71
+ (дальше проверка на ввод с задержкой 400 мс). `submit()` сам метит `touched` — ручной
72
+ `markAsTouched` не нужен.
73
+
74
+ ```tsx
75
+ import { createModel, createForm, useFormValidation } from '@reformer/core';
76
+ import { defineValidationSchema, validate } from '@reformer/core/validation';
77
+ import { required, email, minLength } from '@reformer/core/validators';
78
+
79
+ type RegistrationData = { username: string; email: string; password: string };
80
+
81
+ // Схема — стабильный module-level const (иначе ломается дедуп раннера).
82
+ const registrationValidation = defineValidationSchema<RegistrationData>(({ model }) => {
83
+ validate(model.$.username, [required(), minLength(3)]);
84
+ validate(model.$.email, [required(), email()]);
85
+ validate(model.$.password, [required(), minLength(8)]);
86
+ });
87
+
88
+ function RegistrationForm() {
89
+ const model = useMemo(() => createModel<RegistrationData>({ username: '', email: '', password: '' }), []);
90
+ const form = useMemo(() => createForm({ model }), [model]);
91
+
92
+ const { submit, isValidating } = useFormValidation({
93
+ model,
94
+ schema: registrationValidation,
95
+ strategy: 'afterFirstSubmit',
96
+ debounce: 400,
97
+ });
98
+
99
+ const onSubmit = async (e: React.FormEvent) => {
100
+ e.preventDefault();
101
+ if (await submit()) await api.save(model.get()); // true = нет блокирующих ошибок
102
+ };
103
+
104
+ return (
105
+ <form onSubmit={onSubmit}>
106
+ <FormField control={form.username} />
107
+ <FormField control={form.email} />
108
+ <FormField control={form.password} />
109
+ <button type="submit" disabled={isValidating}>Отправить</button>
110
+ </form>
111
+ );
112
+ }
113
+ ```
114
+
115
+ ### Wizard — live-стратегия внутри активного шага
116
+
117
+ `defineSteps(..., { strategy })` задаёт ЖИВУЮ стратегию ВНУТРИ шага; `useWizardStepValidation`
118
+ арминует её под текущий шаг (`currentStep` из `FormWizardContext`) и снимает при смене шага / unmount.
119
+ Per-step gate (`validateStep` на «Далее») и `validateAll` (submit) при этом НЕ меняются — стратегия
120
+ добавляет живой слой ПОВЕРХ них. No-op, если `strategy` не задана / `'submit'` / у шага нет правил.
121
+
122
+ ```tsx
123
+ import { defineSteps, useWizardStepValidation, FormWizard } from '@reformer/cdk/form-wizard';
124
+
125
+ const config = defineSteps<'loan' | 'applicant' | 'confirm', CreditForm>(model, {
126
+ steps: { loan: loanStep, applicant: applicantStep, confirm: null }, // confirm — без правил, ЯВНО
127
+ extras: crossFieldRules, // применяются только в validateAll (submit)
128
+ strategy: 'blur', // живая валидация полей текущего шага при потере фокуса
129
+ });
130
+
131
+ function LoanStepBody() {
132
+ useWizardStepValidation(config); // армит 'blur' под активный шаг, снимает при переходе
133
+ return <>{/* поля шага */}</>;
134
+ }
135
+
136
+ <FormWizard form={form} config={config}>
137
+ <LoanStepBody />
138
+ </FormWizard>;
139
+ ```
140
+
141
+ ### Headless / SSR / тесты — createFormValidation
142
+
143
+ ```typescript
144
+ import { createFormValidation } from '@reformer/core/validation';
145
+
146
+ const ctrl = createFormValidation(model, registrationValidation, {
147
+ strategy: 'afterFirstSubmit',
148
+ debounce: 300,
149
+ });
150
+ const dispose = ctrl.start(); // ТОЛЬКО на клиенте (в React — из useEffect)
151
+ const ok = await ctrl.validate(); // submit → раскрыть все ошибки
152
+ dispose();
153
+ ```
154
+
155
+ ## Anti-patterns
156
+
157
+ ```typescript
158
+ // ❌ schema пересоздаётся каждый рендер → контроллер и дедуп раннера ломаются
159
+ function MyForm() {
160
+ const schema = defineValidationSchema<Form>(({ model }) => { … }); // новая ссылка на каждый рендер
161
+ useFormValidation({ model, schema, strategy: 'change' });
162
+ }
163
+
164
+ // ✅ Стабильная ссылка: module-level const (или useMemo с пустыми deps)
165
+ const formValidation = defineValidationSchema<Form>(({ model }) => { … });
166
+ function MyForm() {
167
+ useFormValidation({ model, schema: formValidation, strategy: 'change' });
168
+ }
169
+ ```
170
+
171
+ ```typescript
172
+ // ❌ Node-level updateOn (Слой B) И активная schema-стратегия на ОДНОМ поле → оба пишут ошибки в ноду → мерцание
173
+ // узел поля: { updateOn: 'blur', … } + useFormValidation({ strategy: 'blur', schema: включает это же поле })
174
+
175
+ // ✅ Одно поле обслуживает ОДИН слой: либо node-level updateOn, либо schema-стратегия — не оба сразу
176
+ ```
177
+
178
+ ```typescript
179
+ // ❌ start() на сервере — арминует реактивные подписки при SSR
180
+ const ctrl = createFormValidation(model, schema, { strategy: 'change' });
181
+ ctrl.start(); // на сервере подписок быть не должно
182
+
183
+ // ✅ Фабрика чиста до start(); армить только на клиенте (useFormValidation делает это в useEffect)
184
+ const ctrl = createFormValidation(model, schema, { strategy: 'change' });
185
+ if (typeof window !== 'undefined') ctrl.start();
186
+ ```
187
+
188
+ ## See also
189
+
190
+ - [28-submit-and-reset.md](./28-submit-and-reset.md) — submit-флоу и `validateModel` (движок под стратегией)
191
+ - [27-revalidate-when.md](./27-revalidate-when.md) — ручные триггеры перевалидации (низкоуровневый примитив)
192
+ - [31-async-validator-debounce.md](./31-async-validator-debounce.md) — async-правила и отмена устаревших прогонов
193
+ - [13-multi-step.md](./13-multi-step.md) — per-step / полная валидация wizard (`validateStep` / `validateAll`)