@reformer/core 10.1.0 → 11.0.0-beta.1

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 (67) hide show
  1. package/dist/{behaviors-node-DUrqAqp1.js → behaviors-node-DGEFMTQu.js} +1 -1
  2. package/dist/{behaviors-value-lCpqicfa.js → behaviors-value-C7dSEhCc.js} +43 -57
  3. package/dist/behaviors.js +108 -107
  4. package/dist/derived-registry-CluZCQ7U.js +16 -0
  5. package/dist/form/form-submitter.d.ts +0 -4
  6. package/dist/form/hooks/types.d.ts +3 -3
  7. package/dist/form/hooks/use-form-validation.d.ts +48 -0
  8. package/dist/form/index.d.ts +2 -3
  9. package/dist/form/nodes/array-node.d.ts +0 -84
  10. package/dist/form/nodes/form-node.d.ts +0 -14
  11. package/dist/form/nodes/group-node.d.ts +0 -4
  12. package/dist/form/status-machine.d.ts +0 -38
  13. package/dist/form/types/contracts.d.ts +0 -6
  14. package/dist/form/types/deep-schema.d.ts +0 -9
  15. package/dist/form/types/index.d.ts +4 -38
  16. package/dist/form/types/schema-node.d.ts +13 -16
  17. package/dist/form/types/validation-schema.d.ts +0 -25
  18. package/dist/form/unique-id.d.ts +3 -11
  19. package/dist/form/validation-schema.d.ts +16 -3
  20. package/dist/form/validation-strategy.d.ts +55 -0
  21. package/dist/form/validators.d.ts +8 -3
  22. package/dist/form-model-DYCzGGKF.js +286 -0
  23. package/dist/index.d.ts +1 -1
  24. package/dist/index.js +477 -648
  25. package/dist/runtime-token.d.ts +23 -0
  26. package/dist/state/form-model.d.ts +12 -1
  27. package/dist/state.js +17 -15
  28. package/dist/subscription-manager-705bls97.js +171 -0
  29. package/dist/validation.js +133 -81
  30. package/dist/validators.d.ts +0 -4
  31. package/dist/validators.js +88 -122
  32. package/docs/llms/01-api-reference.md +63 -0
  33. package/docs/llms/02-quick-start.md +143 -0
  34. package/docs/llms/03-api-signatures.md +273 -0
  35. package/docs/llms/04-common-patterns.md +202 -0
  36. package/docs/llms/05-common-mistakes.md +167 -0
  37. package/docs/llms/06-troubleshooting.md +11 -0
  38. package/docs/llms/07-complete-import.md +99 -0
  39. package/docs/llms/08-form-types.md +47 -0
  40. package/docs/llms/09-formschema.md +143 -0
  41. package/docs/llms/10-arrays.md +161 -0
  42. package/docs/llms/11-async-watchfield.md +53 -0
  43. package/docs/llms/12-array-cleanup.md +31 -0
  44. package/docs/llms/13-multi-step.md +91 -0
  45. package/docs/llms/14-extended-mistakes.md +98 -0
  46. package/docs/llms/15-project-structure.md +125 -0
  47. package/docs/llms/16-ui-components.md +189 -0
  48. package/docs/llms/17-nonexistent-api.md +93 -0
  49. package/docs/llms/18-conditional-fields.md +183 -0
  50. package/docs/llms/19-reading-values.md +59 -0
  51. package/docs/llms/20-compute-vs-watch.md +138 -0
  52. package/docs/llms/21-array-operations.md +113 -0
  53. package/docs/llms/22-cycle-detection.md +86 -0
  54. package/docs/llms/23-copy-from.md +141 -0
  55. package/docs/llms/24-sync-fields.md +114 -0
  56. package/docs/llms/25-reset-when.md +115 -0
  57. package/docs/llms/26-transform-value.md +136 -0
  58. package/docs/llms/27-revalidate-when.md +132 -0
  59. package/docs/llms/28-submit-and-reset.md +259 -0
  60. package/docs/llms/29-async-preload.md +193 -0
  61. package/docs/llms/30-type-safety-recipes.md +159 -0
  62. package/docs/llms/31-async-validator-debounce.md +135 -0
  63. package/docs/llms/32-async-options-loading.md +108 -0
  64. package/docs/llms/33-validation-strategy.md +193 -0
  65. package/llms.txt +435 -1580
  66. package/package.json +33 -10
  67. package/dist/form-model-C8Fi83k4.js +0 -439
@@ -1,200 +1,166 @@
1
- import { required as u } from "./validators/required.js";
2
- import { min as a } from "./validators/min.js";
3
- import { max as p } from "./validators/max.js";
4
- import { minLength as c } from "./validators/min-length.js";
5
- import { maxLength as g } from "./validators/max-length.js";
6
- import { email as d } from "./validators/email.js";
7
- import { pattern as z } from "./validators/pattern.js";
8
- import { url as h } from "./validators/url.js";
9
- import { phone as y } from "./validators/phone.js";
10
- import { isNumber as F } from "./validators/is-number.js";
11
- import { integer as x } from "./validators/integer.js";
12
- import { multipleOf as S } from "./validators/multiple-of.js";
13
- import { nonNegative as b } from "./validators/non-negative.js";
14
- import { nonZero as A } from "./validators/non-zero.js";
15
- import { isDate as L } from "./validators/is-date.js";
16
- import { minDate as T } from "./validators/min-date.js";
17
- import { maxDate as C } from "./validators/max-date.js";
18
- import { pastDate as D } from "./validators/past-date.js";
19
- import { futureDate as N } from "./validators/future-date.js";
20
- import { minAge as W } from "./validators/min-age.js";
21
- import { maxAge as _ } from "./validators/max-age.js";
22
- function m(r) {
1
+ import { required as z } from "./validators/required.js";
2
+ import { min as y } from "./validators/min.js";
3
+ import { max as b } from "./validators/max.js";
4
+ import { minLength as L } from "./validators/min-length.js";
5
+ import { maxLength as D } from "./validators/max-length.js";
6
+ import { email as T } from "./validators/email.js";
7
+ import { pattern as w } from "./validators/pattern.js";
8
+ import { url as k } from "./validators/url.js";
9
+ import { phone as B } from "./validators/phone.js";
10
+ import { isNumber as Z } from "./validators/is-number.js";
11
+ import { integer as G } from "./validators/integer.js";
12
+ import { multipleOf as I } from "./validators/multiple-of.js";
13
+ import { nonNegative as K } from "./validators/non-negative.js";
14
+ import { nonZero as P } from "./validators/non-zero.js";
15
+ import { isDate as R } from "./validators/is-date.js";
16
+ import { minDate as V } from "./validators/min-date.js";
17
+ import { maxDate as Y } from "./validators/max-date.js";
18
+ import { pastDate as $ } from "./validators/past-date.js";
19
+ import { futureDate as ee } from "./validators/future-date.js";
20
+ import { minAge as ne } from "./validators/min-age.js";
21
+ import { maxAge as ie } from "./validators/max-age.js";
22
+ function s(r) {
23
23
  if (typeof r != "object" || r === null) return !1;
24
- const i = r;
25
- return !(typeof i.name != "string" || i.size !== void 0 && typeof i.size != "number" || i.type !== void 0 && typeof i.type != "string");
24
+ const t = r;
25
+ return !(typeof t.name != "string" || t.size !== void 0 && typeof t.size != "number" || t.type !== void 0 && typeof t.type != "string");
26
26
  }
27
27
  function o(r) {
28
- return Array.isArray(r) ? r.filter(m) : m(r) ? [r] : null;
28
+ return Array.isArray(r) ? r.filter(s) : s(r) ? [r] : null;
29
29
  }
30
- function s(r, i) {
31
- const e = i.split(",").map((l) => l.trim().toLowerCase()).filter(Boolean);
30
+ function u(r, t) {
31
+ const e = t.split(",").map((l) => l.trim().toLowerCase()).filter(Boolean);
32
32
  if (e.length === 0) return !0;
33
- const n = r.name.toLowerCase(), t = (r.type ?? "").toLowerCase();
34
- return e.some((l) => l.startsWith(".") ? n.endsWith(l) : l.endsWith("/*") ? t.startsWith(l.slice(0, -1)) : l.includes("/") ? t === l : !1);
33
+ const n = r.name.toLowerCase(), i = (r.type ?? "").toLowerCase();
34
+ return e.some((l) => l.startsWith(".") ? n.endsWith(l) : l.endsWith("/*") ? i.startsWith(l.slice(0, -1)) : l.includes("/") ? i === l : !1);
35
35
  }
36
- function j(r, i) {
36
+ function m(r, t) {
37
37
  return (e) => {
38
38
  if (e == null || e === "")
39
39
  return null;
40
40
  const n = o(e);
41
41
  if (n === null || n.length === 0) return null;
42
- for (const t of n)
43
- if (typeof t.size == "number" && t.size > r)
42
+ for (const i of n)
43
+ if (typeof i.size == "number" && i.size > r)
44
44
  return {
45
45
  code: "maxFileSize",
46
- message: i?.message ?? "invalid",
46
+ message: t?.message ?? "invalid",
47
47
  params: {
48
48
  maxFileSize: r,
49
- fileName: t.name,
50
- actualSize: t.size,
51
- ...i?.params
49
+ fileName: i.name,
50
+ actualSize: i.size,
51
+ ...t?.params
52
52
  }
53
53
  };
54
54
  return null;
55
55
  };
56
56
  }
57
- function w(r, i) {
57
+ function a(r, t) {
58
58
  return (e) => {
59
59
  if (e == null || e === "")
60
60
  return null;
61
61
  const n = o(e);
62
62
  if (n === null || n.length === 0) return null;
63
- for (const t of n)
64
- if (typeof t.size == "number" && t.size < r)
63
+ for (const i of n)
64
+ if (typeof i.size == "number" && i.size < r)
65
65
  return {
66
66
  code: "minFileSize",
67
- message: i?.message ?? "invalid",
67
+ message: t?.message ?? "invalid",
68
68
  params: {
69
69
  minFileSize: r,
70
- fileName: t.name,
71
- actualSize: t.size,
72
- ...i?.params
70
+ fileName: i.name,
71
+ actualSize: i.size,
72
+ ...t?.params
73
73
  }
74
74
  };
75
75
  return null;
76
76
  };
77
77
  }
78
- function O(r, i) {
78
+ function p(r, t) {
79
79
  return (e) => {
80
80
  if (e == null || e === "")
81
81
  return null;
82
82
  const n = o(e);
83
83
  if (n === null || n.length === 0) return null;
84
- for (const t of n)
85
- if (!s(t, r))
84
+ for (const i of n)
85
+ if (!u(i, r))
86
86
  return {
87
87
  code: "fileType",
88
- message: i?.message ?? "invalid",
89
- params: { accept: r, fileName: t.name, ...i?.params }
88
+ message: t?.message ?? "invalid",
89
+ params: { accept: r, fileName: i.name, ...t?.params }
90
90
  };
91
91
  return null;
92
92
  };
93
93
  }
94
- function k(r, i) {
94
+ function c(r, t) {
95
95
  return (e) => {
96
96
  if (e == null || e === "")
97
97
  return null;
98
98
  const n = o(e);
99
99
  return n === null || n.length === 0 ? null : n.length > r ? {
100
100
  code: "maxFiles",
101
- message: i?.message ?? "invalid",
102
- params: { maxFiles: r, actualCount: n.length, ...i?.params }
101
+ message: t?.message ?? "invalid",
102
+ params: { maxFiles: r, actualCount: n.length, ...t?.params }
103
103
  } : null;
104
104
  };
105
105
  }
106
- function q(r, i) {
106
+ function x(r, t) {
107
107
  return (e) => {
108
108
  if (e == null || e === "")
109
109
  return null;
110
110
  const n = o(e);
111
111
  return n === null || n.length === 0 ? null : n.length < r ? {
112
112
  code: "minFiles",
113
- message: i?.message ?? "invalid",
114
- params: { minFiles: r, actualCount: n.length, ...i?.params }
113
+ message: t?.message ?? "invalid",
114
+ params: { minFiles: r, actualCount: n.length, ...t?.params }
115
115
  } : null;
116
116
  };
117
117
  }
118
- function B(r, i) {
118
+ function g(r, t) {
119
119
  return (e) => {
120
120
  if (e == null || e === "")
121
121
  return null;
122
122
  const n = o(e);
123
123
  if (n === null || n.length === 0) return null;
124
- const t = n.reduce(
124
+ const i = n.reduce(
125
125
  (l, f) => l + (typeof f.size == "number" ? f.size : 0),
126
126
  0
127
127
  );
128
- return t > r ? {
128
+ return i > r ? {
129
129
  code: "maxTotalFileSize",
130
- message: i?.message ?? "invalid",
131
- params: { maxTotalFileSize: r, actualTotal: t, ...i?.params }
130
+ message: t?.message ?? "invalid",
131
+ params: { maxTotalFileSize: r, actualTotal: i, ...t?.params }
132
132
  } : null;
133
133
  };
134
134
  }
135
- const te = /* @__PURE__ */ Object.freeze(/* @__PURE__ */ Object.defineProperty({
136
- __proto__: null,
137
- email: d,
138
- fileType: O,
139
- futureDate: N,
140
- integer: x,
141
- isDate: L,
142
- isFileLike: m,
143
- isNumber: F,
144
- matchesFileAccept: s,
145
- max: p,
146
- maxAge: _,
147
- maxDate: C,
148
- maxFileSize: j,
149
- maxFiles: k,
150
- maxLength: g,
151
- maxTotalFileSize: B,
152
- min: a,
153
- minAge: W,
154
- minDate: T,
155
- minFileSize: w,
156
- minFiles: q,
157
- minLength: c,
158
- multipleOf: S,
159
- nonNegative: b,
160
- nonZero: A,
161
- pastDate: D,
162
- pattern: z,
163
- phone: y,
164
- required: u,
165
- toFileArray: o,
166
- url: h
167
- }, Symbol.toStringTag, { value: "Module" }));
168
135
  export {
169
- te as default,
170
- d as email,
171
- O as fileType,
172
- N as futureDate,
173
- x as integer,
174
- L as isDate,
175
- m as isFileLike,
176
- F as isNumber,
177
- s as matchesFileAccept,
178
- p as max,
179
- _ as maxAge,
180
- C as maxDate,
181
- j as maxFileSize,
182
- k as maxFiles,
183
- g as maxLength,
184
- B as maxTotalFileSize,
185
- a as min,
186
- W as minAge,
187
- T as minDate,
188
- w as minFileSize,
189
- q as minFiles,
190
- c as minLength,
191
- S as multipleOf,
192
- b as nonNegative,
193
- A as nonZero,
194
- D as pastDate,
195
- z as pattern,
196
- y as phone,
197
- u as required,
136
+ T as email,
137
+ p as fileType,
138
+ ee as futureDate,
139
+ G as integer,
140
+ R as isDate,
141
+ s as isFileLike,
142
+ Z as isNumber,
143
+ u as matchesFileAccept,
144
+ b as max,
145
+ ie as maxAge,
146
+ Y as maxDate,
147
+ m as maxFileSize,
148
+ c as maxFiles,
149
+ D as maxLength,
150
+ g as maxTotalFileSize,
151
+ y as min,
152
+ ne as minAge,
153
+ V as minDate,
154
+ a as minFileSize,
155
+ x as minFiles,
156
+ L as minLength,
157
+ I as multipleOf,
158
+ K as nonNegative,
159
+ P as nonZero,
160
+ $ as pastDate,
161
+ w as pattern,
162
+ B as phone,
163
+ z as required,
198
164
  o as toFileArray,
199
- h as url
165
+ k as url
200
166
  };
@@ -0,0 +1,63 @@
1
+ ## 1. API Reference
2
+
3
+ ### Imports (CRITICALLY IMPORTANT)
4
+
5
+ Архитектура M1: значения живут в **модели** (`createModel`), а форма (`createForm`) строит ноды поверх сигналов модели. Behaviors работают на сигналах (`model.$.field`), а не на строковых путях.
6
+
7
+ | What | Where |
8
+ | ------------------------------------------------------------------------------------------------------ | --------------------------- |
9
+ | `createModel`, `createForm` | `@reformer/core` |
10
+ | `validateModel`, `defineValidationSchema`, `validate`, `validateAsync`, `validateWhen`, `cross`, `each`, `apply` | `@reformer/core/validation` |
11
+ | `Rule`, `AsyncRule`, `ValidationSchema` (типы) | `@reformer/core/validation` |
12
+ | `useFormControl`, `useFormControlValue`, `useArrayLength` | `@reformer/core` |
13
+ | `FormModel`, `FormProxy`, `FieldNode`, `GroupNode`, `ArrayNode`, `ModelArrayNode` | `@reformer/core` |
14
+ | `ModelSignals`, `ModelArray`, `ModelValue`, `ModelObject`, `PathAwareSignal` | `@reformer/core` |
15
+ | `ValidationError`, `FieldConfig`, `FormSchema`, `FieldControlState` | `@reformer/core` |
16
+ | `computeFrom`, `copyFrom`, `watchField`, `enableWhen`, `disableWhen` | `@reformer/core` (примитивы) |
17
+ | `transformValue`, `resetWhen`, `syncFields`, `revalidateWhen` | `@reformer/core` (примитивы) |
18
+ | `required`, `min`, `max`, `minLength`, `maxLength`, `email`, `pattern`, `url`, `phone` | `@reformer/core/validators` |
19
+ | `isNumber`, `integer`, `multipleOf`, `nonNegative`, `nonZero` | `@reformer/core/validators` |
20
+ | `isDate`, `minDate`, `maxDate`, `pastDate`, `futureDate`, `minAge`, `maxAge` | `@reformer/core/validators` |
21
+ | `defineFormBehavior`, `compute`, `computeFrom`, `copyFrom`, `onChange`, `enableWhen`, `disableWhen` | `@reformer/core/behaviors` |
22
+ | `transformValue`, `resetWhen`, `syncFields`, `revalidateWhen`, `apply`, `applyEach`, `aggregateInto` | `@reformer/core/behaviors` |
23
+ | `exclusiveFlag`, `onDispose`, `getScope`, `effect`, `defer` | `@reformer/core/behaviors` |
24
+
25
+ > **Два способа писать behaviors.** Низкоуровневые примитивы (`computeFrom`, `copyFrom`, `watchField`,
26
+ > `enableWhen`, …) экспортируются из `@reformer/core`, принимают **сигналы** (`model.$.x`), возвращают
27
+ > **cleanup-функцию** и вызываются императивно (например, в `useEffect`). Декларативный DSL
28
+ > (`defineFormBehavior` + операторы) экспортируется из `@reformer/core/behaviors`, регистрирует cleanup
29
+ > сам и передаётся в `createForm({ behavior })`. См. `20-compute-vs-watch.md`.
30
+
31
+ > **Валидация — отдельный слой.** Layout-схема `createForm` НЕ несёт валидаторов. Правила живут в
32
+ > `defineValidationSchema<T>(({ model }) => { validate(model.$.x, [required(), min(50000)]); ... })`
33
+ > из `@reformer/core/validation`; фабрики `required()`, `min(50000)`, `email()` возвращают
34
+ > `Rule<T> = (value) => ValidationError | null` и передаются массивом в `validate(sig, [...])`.
35
+ > Запуск — только внешним раннером `await validateModel(model, schema)` (`Promise<boolean>`, ошибки
36
+ > сам роутит в ноды; `form.validate()`/`form.submit()` schema-валидацию НЕ гоняют).
37
+
38
+ ### Type Values
39
+
40
+ - Опциональные числа: `number | null` (конвенция «пользователь очистил поле»)
41
+ - Опциональные строки: `string` (по умолчанию пустая строка) или `string | null`
42
+ - Form-shape тип объявляй как `type`-alias — см. `30-type-safety-recipes.md`
43
+
44
+ ### React Hooks Comparison (CRITICALLY IMPORTANT)
45
+
46
+ | Hook | Return Type | Subscribes To | Use Case |
47
+ |------|-------------|---------------|----------|
48
+ | `useFormControl(field)` | `{ value, errors, disabled, touched, valid, invalid, pending, shouldShowError, componentProps }` | Все сигналы поля | Полное состояние поля, инпуты |
49
+ | `useFormControlValue(field)` | `T` (значение напрямую) | Только сигнал value | Условный рендеринг |
50
+ | `useArrayLength(array)` | `number` | Только длина массива | Реактивная длина массива |
51
+
52
+ **CRITICAL**: Не деструктурируй `useFormControlValue`! Он возвращает `T` напрямую, НЕ `{ value: T }`.
53
+
54
+ ```typescript
55
+ // WRONG - will always be undefined!
56
+ const { value: loanType } = useFormControlValue(control.loanType);
57
+
58
+ // CORRECT
59
+ const loanType = useFormControlValue(control.loanType);
60
+
61
+ // CORRECT - useFormControl returns object, destructuring OK
62
+ const { value, errors, disabled } = useFormControl(control.loanType);
63
+ ```
@@ -0,0 +1,143 @@
1
+ ## 1.5 QUICK START - Minimal Working Form
2
+
3
+ > **Schema-driven UI rule (read first)**: компонент И его пропсы (label, placeholder,
4
+ > options, type) объявляются в **схеме поля** (`component` + `componentProps`).
5
+ > В JSX рендерится один универсальный `<FormField control={form.x} />` из
6
+ > `@reformer/ui-kit` БЕЗ дополнительных props. Не пиши свои `Input`/`Select`/
7
+ > `Checkbox`-обёртки с `label`-prop'ами — это anti-pattern. См.
8
+ > `find_recipe(package="@reformer/ui-kit", topic="form-field-integration")`.
9
+
10
+ Архитектура M1: сначала создаётся **модель данных** (`createModel`), затем **форма**
11
+ (`createForm({ model, schema })`), где схема привязывает поля к сигналам модели
12
+ (`model.$.field`). Layout-схема НЕ несёт валидаторов — валидация живёт в отдельной
13
+ схеме `defineValidationSchema` из `@reformer/core/validation` и запускается внешним
14
+ раннером `validateModel(model, schema)`.
15
+
16
+ ```typescript
17
+ import { createModel, createForm, type FormProxy } from '@reformer/core';
18
+ import { defineValidationSchema, validate, validateModel } from '@reformer/core/validation';
19
+ import { required, email } from '@reformer/core/validators';
20
+ import { FormField, Input, Button } from '@reformer/ui-kit';
21
+
22
+ // 1. Define form type as `type` alias (not `interface` — see Recipe 2)
23
+ type ContactForm = {
24
+ name: string;
25
+ email: string;
26
+ };
27
+
28
+ // 2. Model (источник истины значений)
29
+ const model = createModel<ContactForm>({ name: '', email: '' });
30
+
31
+ // 3. Layout-schema: привязка поля к сигналу (model.$.field) + component/componentProps.
32
+ // БЕЗ validators — валидация в отдельной схеме (шаг 4).
33
+ const schema = {
34
+ name: {
35
+ value: model.$.name,
36
+ component: Input,
37
+ componentProps: { label: 'Name', placeholder: 'Your name' },
38
+ },
39
+ email: {
40
+ value: model.$.email,
41
+ component: Input,
42
+ componentProps: { label: 'Email', type: 'email' },
43
+ },
44
+ };
45
+
46
+ // 4. Validation-schema — отдельный слой (@reformer/core/validation)
47
+ const contactValidation = defineValidationSchema<ContactForm>(({ model }) => {
48
+ validate(model.$.name, [required({ message: 'Name is required' })]);
49
+ validate(model.$.email, [required({ message: 'Email is required' }), email({ message: 'Invalid email' })]);
50
+ });
51
+
52
+ // 5. Form — ноды поверх сигналов модели
53
+ const form = createForm<ContactForm>({ model, schema });
54
+
55
+ // 6. Use in React component — thin JSX, FormField does ALL heavy lifting
56
+ function ContactFormComponent() {
57
+ const handleSubmit = async (e: React.FormEvent) => {
58
+ e.preventDefault();
59
+ // form.submit()/validate() НЕ гоняют schema-валидацию — только внешний validateModel
60
+ const ok = await validateModel(model, contactValidation); // Promise<boolean>, ошибки сам роутит в ноды
61
+ if (ok) {
62
+ console.log('Form submitted:', model.get());
63
+ }
64
+ };
65
+
66
+ return (
67
+ <form onSubmit={handleSubmit}>
68
+ <FormField control={form.name} testId="name" />
69
+ <FormField control={form.email} testId="email" />
70
+ <Button type="submit">Send</Button>
71
+ </form>
72
+ );
73
+ }
74
+
75
+ // 7. Pass form to child components via props (NOT context!)
76
+ type FormStepProps = {
77
+ form: FormProxy<ContactForm>;
78
+ };
79
+
80
+ function FormStep({ form }: FormStepProps) {
81
+ return <FormField control={form.name} testId="name" />;
82
+ }
83
+ ```
84
+
85
+ > **Стабильность инстанса.** В React создавай model/schema/form ОДИН раз через `useMemo(() => { … }, [])`
86
+ > — иначе форма пересоздаётся на каждый рендер. См. `28-submit-and-reset.md`, `29-async-preload.md`.
87
+
88
+ ### Arrays of objects — `{ array, item }` schema node
89
+
90
+ Массивы объектов принадлежат модели (`model.arrayField`). В схеме объявляются узлом
91
+ `{ array: model.<path>, item: (itemModel) => itemSchema }`, где `item` строит под-схему
92
+ для каждого элемента из его под-модели (`FormModel<Item>`):
93
+
94
+ ```typescript
95
+ type PropertyItem = {
96
+ type: 'apartment' | 'house';
97
+ description: string;
98
+ estimatedValue: number;
99
+ };
100
+
101
+ type MyForm = { properties: PropertyItem[] };
102
+
103
+ const model = createModel<MyForm>({ properties: [] });
104
+
105
+ // под-схема одного элемента: item.$.field — сигнал под-модели элемента
106
+ const propertyItem = (item: FormModel<PropertyItem>) => ({
107
+ type: {
108
+ value: item.$.type,
109
+ component: Select,
110
+ componentProps: { label: 'Тип', options: [/* ... */] },
111
+ },
112
+ description: { value: item.$.description, component: Textarea, componentProps: { label: 'Описание' } },
113
+ estimatedValue: {
114
+ value: item.$.estimatedValue,
115
+ component: Input,
116
+ componentProps: { label: 'Стоимость', type: 'number' },
117
+ },
118
+ });
119
+
120
+ const schema = {
121
+ properties: { array: model.properties, item: propertyItem },
122
+ };
123
+
124
+ const form = createForm<MyForm>({ model, schema });
125
+
126
+ // Операции над массивом — на модели:
127
+ model.properties.push({ type: 'apartment', description: '', estimatedValue: 0 });
128
+ model.properties.removeAt(0);
129
+ model.properties.length; // реактивная длина
130
+ ```
131
+
132
+ Подробнее в `10-arrays.md`, `21-array-operations.md` и `find_recipe(topic="form-array")`.
133
+
134
+ ### When to write your own field components (advanced — rare)
135
+
136
+ Свои компоненты нужны ТОЛЬКО если:
137
+
138
+ - ты намеренно избегаешь `@reformer/ui-kit` (например, проект уже имеет свою design system)
139
+ - нужен особый низкоуровневый input, который не покрывается `FormField` + `componentProps`
140
+
141
+ В этом случае см. секцию `## 14.5 UI COMPONENT PATTERNS` ниже — но даже там
142
+ паттерн **schema-driven** (label/options не из JSX-props, а из `componentProps` через
143
+ `useFormControl(...).componentProps`).