@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,132 @@
1
+ # revalidateWhen — Перевалидация по триггерам
2
+
3
+ ## Purpose
4
+
5
+ `revalidateWhen` вызывает переданный колбэк ревалидации, когда изменяется любая из
6
+ зависимостей. Применяется, когда правило зависит от значений других полей
7
+ (`amount <= maxAmount`, `confirmPassword === password`, `initialPayment >= propertyValue * 0.2`).
8
+ Валидация — отдельная функция-схема (`@reformer/core/validation`), прогоняемая по требованию через
9
+ `validateModel(model, schema)`. Поэтому ревалидация выражается явным колбэком, а не автоматической
10
+ привязкой к полю: поведение (`revalidateWhen`) лишь _инициирует_ прогон схемы валидации при изменении зависимости.
11
+
12
+ ## API
13
+
14
+ `revalidateWhen` — оператор поведения (`@reformer/core/behaviors`, либо примитив `@reformer/core`).
15
+ Его сигнатура НЕ зависит от контракта валидации:
16
+
17
+ ```typescript
18
+ // примитив: возвращает cleanup
19
+ function revalidateWhen(deps: ReadonlySignal<unknown>[], revalidate: () => void): () => void;
20
+
21
+ // DSL: cleanup управляется формой
22
+ function revalidateWhen(deps: ReadonlySignal<unknown>[], revalidate: () => void): void;
23
+ ```
24
+
25
+ `deps` — массив сигналов-триггеров (`model.$.field`). `revalidate` — колбэк (обычно
26
+ `() => void validateModel(model, schema)`, где `schema` — `defineValidationSchema<T>(({ model }) => …)`).
27
+ Вызывается при изменении любой зависимости (НЕ на инициализации), вне effect-контекста.
28
+
29
+ ## Examples
30
+
31
+ ### Базовый сценарий — перевалидация amount при смене maxAmount
32
+
33
+ ```typescript
34
+ import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
35
+ import { validateModel } from '@reformer/core/validation';
36
+
37
+ export const paymentBehavior = defineFormBehavior<PaymentForm>(({ model }) => {
38
+ revalidateWhen([model.$.maxAmount], () => {
39
+ void validateModel(model, paymentSchema);
40
+ });
41
+ });
42
+ ```
43
+
44
+ Пример: `revalidateWhen([model.$.maxAmount], () => void validateModel(model, paymentSchema))`.
45
+ Схема `paymentSchema` — стабильный module-level `const` (важно для отмены устаревших прогонов
46
+ в `validateModel`), где правило amount навешано через `cross(model.$.amount, amountVsMax)`.
47
+
48
+ ### Несколько триггеров
49
+
50
+ ```typescript
51
+ import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
52
+ import { validateModel } from '@reformer/core/validation';
53
+
54
+ export const mortgageBehavior = defineFormBehavior<MortgageForm>(({ model }) => {
55
+ // initialPayment зависит и от propertyValue, и от loanAmount
56
+ revalidateWhen([model.$.propertyValue, model.$.loanAmount], () => {
57
+ void validateModel(model, mortgageSchema);
58
+ });
59
+ });
60
+ ```
61
+
62
+ ### Парная перевалидация — confirmPassword
63
+
64
+ ```typescript
65
+ import { defineFormBehavior, revalidateWhen } from '@reformer/core/behaviors';
66
+ import { validateModel } from '@reformer/core/validation';
67
+
68
+ // правило совпадения — cross-field в схеме: cross(model.$.confirmPassword, passwordsMatch),
69
+ // где passwordsMatch читает снапшот формы (см. 03-api-signatures.md)
70
+ export const registrationBehavior = defineFormBehavior<RegistrationForm>(({ model }) => {
71
+ // при смене password перевалидируем схему (confirm перепроверится)
72
+ revalidateWhen([model.$.password], () => {
73
+ void validateModel(model, registrationSchema);
74
+ });
75
+ });
76
+ ```
77
+
78
+ ### Как примитив (вне defineFormBehavior)
79
+
80
+ ```typescript
81
+ import { revalidateWhen } from '@reformer/core';
82
+ import { validateModel } from '@reformer/core/validation';
83
+
84
+ const stop = revalidateWhen([model.$.maxAmount], () => void validateModel(model, schema));
85
+ ```
86
+
87
+ ## Anti-patterns
88
+
89
+ ```typescript
90
+ // ❌ Триггер == поле, которое и так меняется само
91
+ revalidateWhen([model.$.amount], () => validateModel(model, schema));
92
+ // amount и так валидируется при собственном изменении в общем прогоне
93
+
94
+ // ✅ Триггеры — ДРУГИЕ поля, от которых зависит правило amount
95
+ revalidateWhen([model.$.maxAmount, model.$.discount], () => validateModel(model, schema));
96
+ ```
97
+
98
+ ```typescript
99
+ // ❌ Правило только через revalidateWhen, но в схеме нет зависимости от триггера
100
+ revalidateWhen([model.$.maxAmount], () => validateModel(model, schema));
101
+ // а валидатор amount — статичный max(1000), не читает maxAmount
102
+
103
+ // ✅ Сначала cross-field правило (читает снапшот формы) в схеме валидации, потом revalidateWhen
104
+ const amountVsMax = (f: Form): ValidationError | null =>
105
+ f.amount != null && f.maxAmount != null && f.amount > f.maxAmount
106
+ ? { code: 'tooBig', message: '...' }
107
+ : null;
108
+
109
+ const schema = defineValidationSchema<Form>(({ model }) => {
110
+ cross(model.$.amount, amountVsMax); // навешиваем правило на поле amount
111
+ });
112
+ revalidateWhen([model.$.maxAmount], () => void validateModel(model, schema));
113
+ ```
114
+
115
+ ## Troubleshooting
116
+
117
+ **Q: Ошибка target не пропадает после изменения триггера.**
118
+ A: Проверьте, что (1) правило реально читает значение триггера — cross-field `cross(sig, fn)`,
119
+ где `fn` берёт снапшот формы (`model.get()` / `f`); (2) в `deps` передан именно сигнал (`model.$.trigger`);
120
+ (3) колбэк вызывает `validateModel` (роутит ошибки в ноды через `getNodeForSignal(sig).setErrors`).
121
+
122
+ **Q: Как перевалидировать только одно поле, а не всю схему?**
123
+ A: Схема — обычная функция, поэтому опиши стабильную под-схему только для этого поля и прогони её:
124
+ `validateModel(model, oneFieldSchema)`, где
125
+ `const oneFieldSchema = defineValidationSchema<Form>(({ model }) => cross(model.$.amount, amountVsMax))`.
126
+ Раннер выставит/погасит ошибки только затронутых полей (гашение накапливается на пару `(model, schema)`).
127
+
128
+ ## See also
129
+
130
+ - [03-api-signatures.md](./03-api-signatures.md) — cross-field `cross(sig, fn)` и раннер `validateModel`
131
+ - [28-submit-and-reset.md](./28-submit-and-reset.md) — полная валидация формы
132
+ - [20-compute-vs-watch.md](./20-compute-vs-watch.md) — реактивные производные значения
@@ -0,0 +1,259 @@
1
+ # Submit и Reset — Жизненный цикл отправки формы
2
+
3
+ ## Purpose
4
+
5
+ Канонический submit-флоу под M1: «запустить полную валидацию данных (`validateModel`) →
6
+ проверить `boolean`-результат → достать снимок (`model.get()`) → сделать запрос → `model.reset()`».
7
+ Валидация — **отдельный слой** (`@reformer/core/validation`, схема через `defineValidationSchema`), она
8
+ НЕ входит в layout-дерево, которое получает `createForm`. Раннер `validateModel(model, schema)` сам роутит
9
+ ошибки в ноды формы, поэтому UI подсветит проблемные поля, а наружу вернёт лишь `boolean`
10
+ (false = есть блокирующая ошибка; `severity:'warning'` показывается, но submit не блокирует).
11
+ `model.reset()` возвращает значения к initial-снимку.
12
+
13
+ ## API
14
+
15
+ ```typescript
16
+ // Модель (источник истины значений):
17
+ interface ModelApi<T> {
18
+ get(): T; // снимок значений — для submit
19
+ set(value: T): void; // полная установка (все ключи T)
20
+ patch(value: Partial<T>): void; // частичное слияние
21
+ isDirty(): boolean; // отличаются ли значения от initial-снимка
22
+ reset(): void; // вернуть к initial-снимку
23
+ captureInitial(): void; // зафиксировать текущие как новый initial
24
+ }
25
+
26
+ // Валидация данных — headless-раннер: находит ноды по сигналам модели, роутит в них ошибки,
27
+ // отменяет устаревшие прогоны той же (model, schema). Возвращает ТОЛЬКО boolean:
28
+ validateModel<T>(model: FormModel<T>, schema: ValidationSchema<T>): Promise<boolean>
29
+ // true = нет блокирующих ошибок (severity:'warning' не блокирует, но показывается).
30
+ // false = есть блокирующая ошибка; она уже проставлена в ноду → UI подсветит поле.
31
+
32
+ // Схема валидации — отдельный слой над моделью (не смешивается с layout):
33
+ defineValidationSchema<T>(({ model }) => {
34
+ validate(model.$.field, [rules]); // синхронные правила поля
35
+ // validateAsync / validateWhen / cross / each / apply — см. contract-spec
36
+ }): ValidationSchema<T>
37
+
38
+ // Нода поля/формы (для точечной работы с UI-состоянием):
39
+ form.markAsTouched(); // тронуть все поля (показать ошибки)
40
+ form.clearErrors(); // снять ошибки со всех нод
41
+ form.<field>.setErrors([{ code, message }]); // серверная/бизнес-ошибка в конкретное поле
42
+ form.<field>.clearErrors();
43
+ ```
44
+
45
+ ## Examples
46
+
47
+ ### Базовый submit-handler
48
+
49
+ ```tsx
50
+ import { useMemo } from 'react';
51
+ import { createModel, createForm } from '@reformer/core';
52
+ import { defineValidationSchema, validate, validateModel } from '@reformer/core/validation';
53
+ import { required, email, minLength } from '@reformer/core/validators';
54
+
55
+ type RegistrationFormData = { username: string; email: string; password: string };
56
+
57
+ // Валидация — отдельная схема над моделью. НЕ входит в layout, который получает createForm.
58
+ // Стабильная module-level `const`-ссылка — чтобы validateModel мог отменять устаревшие прогоны.
59
+ const registrationSchema = defineValidationSchema<RegistrationFormData>(({ model }) => {
60
+ validate(model.$.username, [required({ message: 'Имя обязательно' }), minLength(3)]);
61
+ validate(model.$.email, [required({ message: 'Email обязателен' }), email()]);
62
+ validate(model.$.password, [required({ message: 'Пароль обязателен' }), minLength(8)]);
63
+ });
64
+
65
+ function RegistrationForm() {
66
+ const { model, form } = useMemo(() => {
67
+ const m = createModel<RegistrationFormData>({ username: '', email: '', password: '' });
68
+ // schema здесь — layout-дерево нод (компоненты/раскладка), без валидаторов.
69
+ return { model: m, form: createForm({ model: m, schema: buildLayout(m) }) };
70
+ }, []);
71
+
72
+ const handleSubmit = async (e: React.FormEvent) => {
73
+ e.preventDefault();
74
+ form.markAsTouched(); // показать ошибки на всех полях
75
+
76
+ // Шаг 1: прогон валидации данных. Ошибки автоматически проставятся в ноды → UI подсветит.
77
+ const valid = await validateModel(model, registrationSchema);
78
+ if (!valid) return;
79
+
80
+ // Шаг 2: достать чистые данные и отправить
81
+ const payload = model.get();
82
+ const response = await fetch('/api/v1/auth/register', {
83
+ method: 'POST',
84
+ headers: { 'Content-Type': 'application/json' },
85
+ body: JSON.stringify(payload),
86
+ });
87
+
88
+ // Шаг 3: чистый старт после успеха
89
+ if (response.ok) model.reset();
90
+ };
91
+
92
+ return (
93
+ <form onSubmit={handleSubmit}>
94
+ <FormField control={form.username} testId="username" />
95
+ <FormField control={form.email} testId="email" />
96
+ <FormField control={form.password} testId="password" />
97
+ <button type="submit">Отправить</button>
98
+ </form>
99
+ );
100
+ }
101
+ ```
102
+
103
+ ### Submit с error-handling и серверными ошибками
104
+
105
+ ```tsx
106
+ async function handleSubmit(e: React.FormEvent) {
107
+ e.preventDefault();
108
+ form.markAsTouched();
109
+
110
+ const valid = await validateModel(model, registrationSchema);
111
+ if (!valid) return;
112
+
113
+ try {
114
+ const response = await api.register(model.get());
115
+ if (response.success) {
116
+ model.reset();
117
+ navigate('/welcome');
118
+ } else {
119
+ // Серверная бизнес-ошибка — НЕ сбрасываем форму, кладём ошибку в поле
120
+ form.username.setErrors([{ code: 'taken', message: response.message }]);
121
+ }
122
+ } catch (error) {
123
+ // Сеть/неожиданная ошибка — оставляем значения
124
+ showToast(`Ошибка сети: ${(error as Error).message}`);
125
+ }
126
+ }
127
+ ```
128
+
129
+ ### Reset с подтверждением
130
+
131
+ ```tsx
132
+ function ActionButtons({ model }: { model: FormModel<RegistrationFormData> }) {
133
+ const handleReset = () => {
134
+ if (!model.isDirty()) return; // нечего сбрасывать
135
+ if (confirm('Очистить форму? Несохранённые изменения будут потеряны.')) {
136
+ model.reset();
137
+ }
138
+ };
139
+
140
+ const clearJustPassword = () => {
141
+ model.password = ''; // сброс одного поля — прямая запись в модель
142
+ };
143
+
144
+ return (
145
+ <>
146
+ <button type="button" onClick={handleReset}>Очистить</button>
147
+ <button type="button" onClick={clearJustPassword}>Очистить только пароль</button>
148
+ </>
149
+ );
150
+ }
151
+ ```
152
+
153
+ ### Использование вне React (server action / Node)
154
+
155
+ Раннер headless — работает без UI-компонентов, только с моделью и схемой:
156
+
157
+ ```typescript
158
+ import { createModel } from '@reformer/core';
159
+ import { validateModel } from '@reformer/core/validation';
160
+
161
+ async function processFormPayload(rawData: Partial<MyForm>) {
162
+ const model = createModel<MyForm>(initialValues);
163
+ model.patch(rawData); // частичный load данных
164
+
165
+ // validateModel находит ноды по сигналам модели и роутит в них ошибки, возвращая boolean.
166
+ const valid = await validateModel(model, myFormSchema);
167
+ if (!valid) return { ok: false as const };
168
+ return { ok: true as const, data: model.get() };
169
+ }
170
+ ```
171
+
172
+ ## Anti-patterns
173
+
174
+ ```typescript
175
+ // ❌ Обращение к .valid: раннер возвращает boolean, а не { valid, errors }
176
+ const result = await validateModel(model, schema);
177
+ if (!result.valid) return; // result — boolean, .valid === undefined → guard никогда не сработает
178
+
179
+ // ✅ Результат — сам boolean
180
+ const valid = await validateModel(model, schema);
181
+ if (!valid) return;
182
+ ```
183
+
184
+ ```typescript
185
+ // ❌ Чтение результата валидации без await — сабмитим по неполному вердикту
186
+ const handleSubmit = (e) => {
187
+ e.preventDefault();
188
+ validateModel(model, schema); // Promise проигнорирован, async-валидаторы не дождались
189
+ submit(model.get());
190
+ };
191
+
192
+ // ✅ Дождаться и проверить boolean
193
+ const handleSubmit = async (e) => {
194
+ e.preventDefault();
195
+ if (await validateModel(model, schema)) submit(model.get());
196
+ };
197
+ ```
198
+
199
+ ```typescript
200
+ // ❌ Валидаторы в layout-дереве, отдаваемом в createForm — layout не несёт правил
201
+ createForm({ model, schema: { username: { value: '', validators: [required()] } } });
202
+
203
+ // ✅ Валидация — отдельная схема, прогоняется раннером
204
+ const schema = defineValidationSchema<T>(({ model }) => {
205
+ validate(model.$.username, [required()]);
206
+ });
207
+ await validateModel(model, schema);
208
+ ```
209
+
210
+ ```typescript
211
+ // ❌ model.reset() до ответа сервера — потеря данных при ошибке
212
+ await api.send(model.get());
213
+ model.reset();
214
+
215
+ // ✅ Reset только после успеха
216
+ try {
217
+ await api.send(model.get());
218
+ model.reset();
219
+ } catch (err) { showError(err); }
220
+ ```
221
+
222
+ ```typescript
223
+ // ❌ Сброс полей по очереди — многословно
224
+ model.username = '';
225
+ model.email = '';
226
+ model.password = '';
227
+
228
+ // ✅ model.reset() — к initial-снимку одним вызовом
229
+ model.reset();
230
+ ```
231
+
232
+ ## Troubleshooting
233
+
234
+ **Q: После `reset()` в UI остались старые ошибки.**
235
+ A: `model.reset()` меняет значения; ошибки в нодах чистит валидация. Перезапусти
236
+ `validateModel(model, schema)` после reset (валидные поля погаснут сами), либо очисти напрямую —
237
+ `form.clearErrors()` / `form.<field>.clearErrors()`.
238
+
239
+ **Q: `validateModel` вернул `true`, но в поле висит ошибка.**
240
+ A: Это правило с `severity: 'warning'` — оно показывается, но submit не блокирует, поэтому раннер
241
+ и вернул `true`. Блокируют только ошибки без `severity` (default). Чтобы не пускать submit при
242
+ warning — проверяй его отдельно, вне `validateModel`.
243
+
244
+ **Q: `reset()` не возвращает данные, загруженные с сервера.**
245
+ A: `reset()` возвращает к initial-снимку (значения на момент `createModel`). `set/patch` НЕ
246
+ меняют initial. Чтобы сделать загруженные данные новой «точкой отсчёта» — вызови
247
+ `model.captureInitial()` после загрузки.
248
+
249
+ **Q: Disabled-поля участвуют в submit-данных?**
250
+ A: `model.get()` возвращает значения всех полей. Фильтруй вручную после `get()` или используй
251
+ `enableWhen({ resetOnDisable: true })`, чтобы при disable поле возвращалось к initial.
252
+
253
+ ## See also
254
+
255
+ - [29-async-preload.md](./29-async-preload.md) — initial values и preload через `set`/`patch`
256
+ - [13-multi-step.md](./13-multi-step.md) — пошаговая валидация через `validateModel` (`validateStep`/`validateAll`)
257
+ - [27-revalidate-when.md](./27-revalidate-when.md) — мост «поведение → валидация»: `revalidateWhen([deps], () => void validateModel(model, schema))`
258
+ - [03-api-signatures.md](./03-api-signatures.md) — сигнатуры модели и `validateModel`
259
+ - [05-common-mistakes.md](./05-common-mistakes.md) — типичные ошибки
@@ -0,0 +1,193 @@
1
+ # Async Preload — Загрузка начальных значений и справочников
2
+
3
+ ## Purpose
4
+
5
+ Загрузка формы данными с сервера под M1: (1) **initial values** в `createModel(...)`; (2)
6
+ **`model.set` / `model.patch`** для загрузки/обновления значений; (3) **external React-hook**
7
+ для full-blown async preload (параллельный fetch заявки + справочников, обработка ошибок,
8
+ race-guard через deps). Динамические `componentProps` (опции селектов) обновляются через
9
+ `form.field.updateComponentProps({ options })` в `queueMicrotask`, чтобы не пересечься с
10
+ реактивными эффектами от `set`/`patch`.
11
+
12
+ ## API
13
+
14
+ ```typescript
15
+ // Модель:
16
+ model.set(value: T): void; // полная установка значений (load полного DTO). Не меняет initial.
17
+ model.patch(value: Partial<T>): void; // частичное слияние (только переданные ключи). Не меняет initial.
18
+ model.reset(): void; // вернуть к initial-снимку
19
+ model.captureInitial(): void; // сделать текущие значения новым initial-снимком
20
+
21
+ // Нода поля/группы:
22
+ form.field.updateComponentProps(props: Record<string, unknown>): void; // динамические опции и т.п.
23
+ ```
24
+
25
+ Initial values задаются в `createModel(initial)`; `reset()` возвращает к ним. Чтобы загруженные
26
+ данные стали новой «точкой отсчёта» для `reset()` — вызови `model.captureInitial()` после load.
27
+
28
+ ## Examples
29
+
30
+ ### Initial values в модели
31
+
32
+ ```typescript
33
+ import { createModel, createForm } from '@reformer/core';
34
+ import { Input, Select } from '@reformer/ui-kit';
35
+
36
+ type ProfileForm = { username: string; language: 'ru' | 'en'; marketing: boolean };
37
+
38
+ const model = createModel<ProfileForm>({ username: '', language: 'ru', marketing: true });
39
+ const schema = {
40
+ username: { value: model.$.username, component: Input, componentProps: { label: 'Username' } },
41
+ language: {
42
+ value: model.$.language,
43
+ component: Select,
44
+ componentProps: {
45
+ label: 'Язык',
46
+ options: [
47
+ { value: 'ru', label: 'Русский' },
48
+ { value: 'en', label: 'English' },
49
+ ],
50
+ },
51
+ },
52
+ marketing: { value: model.$.marketing, component: Input, componentProps: { type: 'checkbox' } },
53
+ };
54
+ const form = createForm({ model, schema });
55
+ // model.get() === { username: '', language: 'ru', marketing: true }
56
+ // после правок: model.reset() возвращает к этим значениям
57
+ ```
58
+
59
+ ### Async preload через external hook + model.set
60
+
61
+ ```tsx
62
+ import { useEffect, useState } from 'react';
63
+ import type { FormModel, FormProxy } from '@reformer/core';
64
+
65
+ interface LoadingState { isLoading: boolean; error: string | null }
66
+
67
+ export function useLoadCreditApplication(
68
+ model: FormModel<CreditApplicationForm>,
69
+ form: FormProxy<CreditApplicationForm>,
70
+ applicationId: string | null
71
+ ): LoadingState {
72
+ const [state, setState] = useState<LoadingState>({ isLoading: !!applicationId, error: null });
73
+
74
+ useEffect(() => {
75
+ if (!applicationId) { setState({ isLoading: false, error: null }); return; }
76
+ let cancelled = false;
77
+
78
+ (async () => {
79
+ setState({ isLoading: true, error: null });
80
+ try {
81
+ const [appResp, dictsResp] = await Promise.all([
82
+ fetchCreditApplication(applicationId),
83
+ fetchDictionaries(),
84
+ ]);
85
+ if (cancelled) return; // race-guard: сменили applicationId / unmount
86
+ if (appResp.status !== 200 || dictsResp.status !== 200) throw new Error('Сервер вернул ошибку');
87
+
88
+ // Загрузка значений в модель
89
+ model.set(appResp.data);
90
+
91
+ // Динамические componentProps — через queueMicrotask (после реактивных эффектов от set)
92
+ queueMicrotask(() => {
93
+ if (cancelled) return;
94
+ form.registrationAddress.city.updateComponentProps({ options: dictsResp.data.cities });
95
+ });
96
+
97
+ setState({ isLoading: false, error: null });
98
+ } catch (err) {
99
+ if (cancelled) return;
100
+ setState({ isLoading: false, error: err instanceof Error ? err.message : 'Ошибка' });
101
+ }
102
+ })();
103
+
104
+ return () => { cancelled = true; };
105
+ }, [applicationId]); // model/form стабильны (создан через useMemo)
106
+
107
+ return state;
108
+ }
109
+ ```
110
+
111
+ ### Preload через behavior — onChange на поле
112
+
113
+ Загрузка справочника при выборе другого поля (после preload) — через `onChange`:
114
+
115
+ ```typescript
116
+ import { defineFormBehavior, onChange } from '@reformer/core/behaviors';
117
+
118
+ export const addressBehavior = defineFormBehavior<AddressForm>(({ model, form }) => {
119
+ onChange(
120
+ model.$.region,
121
+ async (region, { signal }) => {
122
+ if (!region) { form.city.updateComponentProps({ options: [] }); return; }
123
+ try {
124
+ const cities = await fetchCities(region, { signal });
125
+ form.city.updateComponentProps({ options: cities });
126
+ } catch {
127
+ form.city.updateComponentProps({ options: [] });
128
+ }
129
+ },
130
+ { debounce: 300 }
131
+ );
132
+ });
133
+ ```
134
+
135
+ ## Anti-patterns
136
+
137
+ ```typescript
138
+ // ❌ model/form пересоздаются на каждый рендер → preload запускается каждый раз
139
+ function MyForm() {
140
+ const model = createModel<T>(initial); // КАЖДЫЙ рендер!
141
+ const form = createForm({ model, schema });
142
+ }
143
+
144
+ // ✅ Стабильные ссылки через useMemo
145
+ function MyForm() {
146
+ const { model, form } = useMemo(() => {
147
+ const m = createModel<T>(initial);
148
+ return { model: m, form: createForm({ model: m, schema: buildSchema(m) }) };
149
+ }, []);
150
+ }
151
+ ```
152
+
153
+ ```typescript
154
+ // ❌ updateComponentProps синхронно после set → возможный конфликт с реактивными эффектами
155
+ model.set(data);
156
+ form.region.updateComponentProps({ options: [...] });
157
+
158
+ // ✅ queueMicrotask, чтобы реактивные эффекты от set завершились
159
+ model.set(data);
160
+ queueMicrotask(() => form.region.updateComponentProps({ options: [...] }));
161
+ ```
162
+
163
+ ```typescript
164
+ // ❌ Нет race-guard в async useEffect
165
+ useEffect(() => { fetchData(id).then((d) => model.set(d)); }, [id]);
166
+
167
+ // ✅ Cleanup-флаг
168
+ useEffect(() => {
169
+ let cancelled = false;
170
+ fetchData(id).then((d) => { if (!cancelled) model.set(d); });
171
+ return () => { cancelled = true; };
172
+ }, [id]);
173
+ ```
174
+
175
+ ## Troubleshooting
176
+
177
+ **Q: После `set`/`patch` не показываются ошибки.**
178
+ A: `set`/`patch` не запускают валидацию. После загрузки вызови `await validateModel(model, schema)` (внешний раннер из `@reformer/core/validation`).
179
+
180
+ **Q: Опции в `<Select>` не появляются после `updateComponentProps`.**
181
+ A: (1) Оберни вызов в `queueMicrotask`; (2) убедись, что компонент подписан через `useFormControl`;
182
+ (3) для массивов — пройдись по элементам (`form.items.map`) и обнови каждый.
183
+
184
+ **Q: При `reset()` теряются данные с сервера.**
185
+ A: `reset()` возвращает к initial-снимку из `createModel`, не к последнему `set`. Чтобы «сбросить
186
+ к данным с сервера» — после `set` вызови `model.captureInitial()` (новая точка отсчёта).
187
+
188
+ ## See also
189
+
190
+ - [28-submit-and-reset.md](./28-submit-and-reset.md) — обратная сторона жизненного цикла
191
+ - [11-async-watchfield.md](./11-async-watchfield.md) — `onChange` для динамики после preload
192
+ - [22-cycle-detection.md](./22-cycle-detection.md) — почему `queueMicrotask` нужен
193
+ - [16-ui-components.md](./16-ui-components.md) — `updateComponentProps` для динамических опций