@reformer/core 1.1.0-beta.3 → 1.1.0-beta.5

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.
package/llms.txt CHANGED
@@ -6,7 +6,7 @@
6
6
 
7
7
  | What | Where |
8
8
  | ------------------------------------------------------------------------------------------- | --------------------------- |
9
- | `createForm`, `useFormControl`, `useFormControlValue` | `@reformer/core` |
9
+ | `createForm`, `useFormControl`, `useFormControlValue`, `validateForm` | `@reformer/core` |
10
10
  | `ValidationSchemaFn`, `BehaviorSchemaFn`, `FieldPath`, `GroupNodeWithControls`, `FieldNode` | `@reformer/core` |
11
11
  | `FormSchema`, `FieldConfig`, `ArrayNode` | `@reformer/core` |
12
12
  | `required`, `min`, `max`, `minLength`, `maxLength`, `email` | `@reformer/core/validators` |
@@ -23,6 +23,98 @@
23
23
  - Optional strings: `string` (empty string by default)
24
24
  - Do NOT add `[key: string]: unknown` to form interfaces
25
25
 
26
+ ### React Hooks Comparison (CRITICALLY IMPORTANT)
27
+
28
+ | Hook | Return Type | Subscribes To | Use Case |
29
+ |------|-------------|---------------|----------|
30
+ | `useFormControl(field)` | `{ value, errors, disabled, touched, ... }` | All signals | Full field state, form inputs |
31
+ | `useFormControlValue(field)` | `T` (value directly) | Only value signal | Conditional rendering |
32
+
33
+ ⚠️ **CRITICAL**: Do NOT destructure `useFormControlValue`! It returns `T` directly, NOT `{ value: T }`.
34
+
35
+ ```typescript
36
+ // ❌ WRONG - will always be undefined!
37
+ const { value: loanType } = useFormControlValue(control.loanType);
38
+
39
+ // ✅ CORRECT
40
+ const loanType = useFormControlValue(control.loanType);
41
+
42
+ // ✅ CORRECT - useFormControl returns object, destructuring OK
43
+ const { value, errors, disabled } = useFormControl(control.loanType);
44
+ ```
45
+
46
+ ## 1.5 QUICK START - Minimal Working Form
47
+
48
+ ```typescript
49
+ import { createForm, useFormControl } from '@reformer/core';
50
+ import { required, email } from '@reformer/core/validators';
51
+ import type { GroupNodeWithControls } from '@reformer/core';
52
+
53
+ // 1. Define form type
54
+ interface ContactForm {
55
+ name: string;
56
+ email: string;
57
+ }
58
+
59
+ // 2. Create form schema with validation
60
+ const form = createForm<ContactForm>({
61
+ form: {
62
+ name: { value: '', component: Input },
63
+ email: { value: '', component: Input },
64
+ },
65
+ validation: (path) => {
66
+ required(path.name, { message: 'Name is required' });
67
+ required(path.email, { message: 'Email is required' });
68
+ email(path.email, { message: 'Invalid email format' });
69
+ },
70
+ });
71
+
72
+ // 3. Use in React component
73
+ function ContactFormComponent() {
74
+ const nameCtrl = useFormControl(form.name);
75
+ const emailCtrl = useFormControl(form.email);
76
+
77
+ const handleSubmit = async () => {
78
+ await form.submit((values) => {
79
+ console.log('Form submitted:', values);
80
+ });
81
+ };
82
+
83
+ return (
84
+ <form onSubmit={(e) => { e.preventDefault(); handleSubmit(); }}>
85
+ <div>
86
+ <input
87
+ value={nameCtrl.value}
88
+ onChange={(e) => form.name.setValue(e.target.value)}
89
+ disabled={nameCtrl.disabled}
90
+ />
91
+ {nameCtrl.errors.map((err) => <span key={err.code}>{err.message}</span>)}
92
+ </div>
93
+ <div>
94
+ <input
95
+ value={emailCtrl.value}
96
+ onChange={(e) => form.email.setValue(e.target.value)}
97
+ disabled={emailCtrl.disabled}
98
+ />
99
+ {emailCtrl.errors.map((err) => <span key={err.code}>{err.message}</span>)}
100
+ </div>
101
+ <button type="submit">Send</button>
102
+ </form>
103
+ );
104
+ }
105
+
106
+ // 4. Pass form to child components via props (NOT context!)
107
+ interface FormStepProps {
108
+ form: GroupNodeWithControls<ContactForm>;
109
+ }
110
+
111
+ function FormStep({ form }: FormStepProps) {
112
+ // Access form fields directly
113
+ const { value } = useFormControl(form.name);
114
+ return <div>Name: {value}</div>;
115
+ }
116
+ ```
117
+
26
118
  ## 2. API SIGNATURES
27
119
 
28
120
  ### Validators
@@ -48,9 +140,48 @@ validate(path, validator: (value, ctx) => ValidationError | null)
48
140
  validateAsync(path, validator: async (value, ctx) => ValidationError | null)
49
141
  validateTree(validator: (ctx) => ValidationError | null, options?: { targetField?: string })
50
142
 
51
- // Conditional validation
143
+ // Conditional validation (3 arguments!)
52
144
  applyWhen(fieldPath, condition: (fieldValue) => boolean, validatorsFn: (path) => void)
53
145
 
146
+ // ⚠️ applyWhen Examples - CRITICALLY IMPORTANT
147
+ // applyWhen takes 3 arguments: triggerField, condition, validators
148
+
149
+ // Example 1: Simple condition
150
+ applyWhen(
151
+ path.loanType, // 1st: field to watch
152
+ (type) => type === 'mortgage', // 2nd: condition on field value
153
+ (p) => { // 3rd: validators to apply
154
+ required(p.propertyValue);
155
+ min(p.propertyValue, 100000);
156
+ }
157
+ );
158
+
159
+ // Example 2: Nested field as trigger
160
+ applyWhen(
161
+ path.address.country,
162
+ (country) => country === 'US',
163
+ (p) => {
164
+ required(p.address.state);
165
+ pattern(p.address.zip, /^\d{5}(-\d{4})?$/);
166
+ }
167
+ );
168
+
169
+ // Example 3: Boolean trigger
170
+ applyWhen(
171
+ path.hasInsurance,
172
+ (has) => has === true,
173
+ (p) => {
174
+ required(p.insuranceCompany);
175
+ required(p.policyNumber);
176
+ }
177
+ );
178
+
179
+ // ❌ WRONG - only 2 arguments (React Hook Form pattern)
180
+ applyWhen(
181
+ (form) => form.loanType === 'mortgage', // WRONG!
182
+ () => { required(path.propertyValue); }
183
+ );
184
+
54
185
  // Array validators
55
186
  notEmpty(path, options?: { message?: string })
56
187
  validateItems(arrayPath, itemValidatorsFn: (itemPath) => void)
@@ -89,7 +220,7 @@ transformers.trim, transformers.toUpperCase, transformers.toLowerCase, transform
89
220
  interface BehaviorContext<TForm> {
90
221
  form: GroupNodeWithControls<TForm>; // Form proxy with typed field access
91
222
  setFieldValue: (path: string, value: any) => void;
92
- getFieldValue: (path: string) => unknown;
223
+ // ⚠️ To READ field values, use: ctx.form.fieldName.value.value
93
224
  }
94
225
  ```
95
226
 
@@ -121,6 +252,35 @@ const { value } = useFormControl(form.field as FieldNode<ExpectedType>);
121
252
 
122
253
  ## 4. ⚠️ COMMON MISTAKES
123
254
 
255
+ ### useFormControlValue (CRITICAL)
256
+
257
+ ```typescript
258
+ // ❌ WRONG - useFormControlValue returns T directly, NOT { value: T }
259
+ const { value: loanType } = useFormControlValue(control.loanType);
260
+ // Result: loanType is ALWAYS undefined! Conditional rendering will fail.
261
+
262
+ // ✅ CORRECT
263
+ const loanType = useFormControlValue(control.loanType);
264
+
265
+ // ✅ ALSO CORRECT - useFormControl returns object
266
+ const { value, errors } = useFormControl(control.loanType);
267
+ ```
268
+
269
+ ### Reading Field Values in BehaviorContext (CRITICAL)
270
+
271
+ ```typescript
272
+ // ❌ WRONG - getFieldValue does NOT exist!
273
+ watchField(path.amount, (amount, ctx) => {
274
+ const rate = ctx.getFieldValue('rate'); // ERROR: Property 'getFieldValue' does not exist
275
+ });
276
+
277
+ // ✅ CORRECT - use ctx.form.fieldName.value.value
278
+ watchField(path.amount, (amount, ctx) => {
279
+ const rate = ctx.form.rate.value.value; // Read via signal
280
+ ctx.setFieldValue('total', amount * rate);
281
+ });
282
+ ```
283
+
124
284
  ### Validators
125
285
 
126
286
  ```typescript
@@ -343,6 +503,25 @@ form.address.city.value.value; // Get current value
343
503
  form.items.push({ id: '1', name: 'Item' }); // Array operations
344
504
  ```
345
505
 
506
+ ### ⚠️ createForm Returns a Proxy
507
+
508
+ ```typescript
509
+ // createForm() returns GroupNodeWithControls<T> (a Proxy wrapper around GroupNode)
510
+ // This enables type-safe field access:
511
+ const form = createForm<MyForm>({...});
512
+
513
+ form.email // FieldNode<string> - TypeScript knows the type!
514
+ form.address.city // FieldNode<string> - nested access works
515
+ form.items.at(0) // GroupNodeWithControls<ItemType> - array items
516
+
517
+ // ⚠️ IMPORTANT: Proxy doesn't pass instanceof checks!
518
+ // Use type guards instead:
519
+ import { isFieldNode, isGroupNode, isArrayNode } from '@reformer/core';
520
+
521
+ if (isFieldNode(node)) { /* ... */ } // ✅ Works with Proxy
522
+ if (node instanceof FieldNode) { /* ... */ } // ❌ Fails with Proxy!
523
+ ```
524
+
346
525
  ## 9. ARRAY SCHEMA FORMAT
347
526
 
348
527
  **Array items are sub-forms!** Each array element is a complete sub-form with its own fields, validation, and behavior.
@@ -473,6 +652,61 @@ export const fullValidation: ValidationSchemaFn<Form> = (path) => {
473
652
  step1Validation(path);
474
653
  step2Validation(path);
475
654
  };
655
+
656
+ // Using validateForm() for step validation
657
+ import { validateForm } from '@reformer/core';
658
+
659
+ const goToNextStep = async () => {
660
+ const currentValidation = STEP_VALIDATIONS[currentStep];
661
+ const isValid = await validateForm(form, currentValidation);
662
+
663
+ if (!isValid) {
664
+ form.markAsTouched(); // Show errors on current step fields
665
+ return;
666
+ }
667
+
668
+ setCurrentStep(currentStep + 1);
669
+ };
670
+
671
+ // Full form submit with all validations
672
+ const handleSubmit = async () => {
673
+ const isValid = await validateForm(form, fullValidation);
674
+
675
+ if (isValid) {
676
+ await form.submit(onSubmit);
677
+ }
678
+ };
679
+ ```
680
+
681
+ ### Multi-Step Component Example
682
+
683
+ ```tsx
684
+ function MultiStepForm() {
685
+ const [step, setStep] = useState(1);
686
+
687
+ const nextStep = async () => {
688
+ const validation = STEP_VALIDATIONS[step];
689
+ if (await validateForm(form, validation)) {
690
+ setStep(step + 1);
691
+ } else {
692
+ form.markAsTouched();
693
+ }
694
+ };
695
+
696
+ return (
697
+ <div>
698
+ {step === 1 && <Step1Fields form={form} />}
699
+ {step === 2 && <Step2Fields form={form} />}
700
+
701
+ <button onClick={() => setStep(step - 1)} disabled={step === 1}>
702
+ Back
703
+ </button>
704
+ <button onClick={step === 2 ? handleSubmit : nextStep}>
705
+ {step === 2 ? 'Submit' : 'Next'}
706
+ </button>
707
+ </div>
708
+ );
709
+ }
476
710
  ```
477
711
 
478
712
  ## 13. ⚠️ EXTENDED COMMON MISTAKES
@@ -593,6 +827,123 @@ export const creditApplicationValidation: ValidationSchemaFn<Form> = (path) => {
593
827
  | Medium | Separate files: `type.ts`, `schema.ts`, `validators.ts`, `Form.tsx` |
594
828
  | Complex | Full colocation with `steps/` and `sub-forms/` |
595
829
 
830
+ ## 14.5 UI COMPONENT PATTERNS
831
+
832
+ ReFormer does NOT provide UI components - you create them yourself or use a UI library.
833
+
834
+ ### Generic FormField Component
835
+
836
+ ```tsx
837
+ import type { FieldNode } from '@reformer/core';
838
+ import { useFormControl } from '@reformer/core';
839
+
840
+ interface FormFieldProps<T> {
841
+ control: FieldNode<T>;
842
+ label?: string;
843
+ type?: 'text' | 'email' | 'number' | 'password';
844
+ placeholder?: string;
845
+ }
846
+
847
+ function FormField<T extends string | number>({
848
+ control,
849
+ label,
850
+ type = 'text',
851
+ placeholder
852
+ }: FormFieldProps<T>) {
853
+ const { value, errors, disabled, touched } = useFormControl(control);
854
+ const showError = touched && errors.length > 0;
855
+
856
+ return (
857
+ <div className="form-field">
858
+ {label && <label>{label}</label>}
859
+ <input
860
+ type={type}
861
+ value={value ?? ''}
862
+ onChange={(e) => {
863
+ const val = type === 'number'
864
+ ? Number(e.target.value) as T
865
+ : e.target.value as T;
866
+ control.setValue(val);
867
+ }}
868
+ onBlur={() => control.markAsTouched()}
869
+ disabled={disabled}
870
+ placeholder={placeholder}
871
+ className={showError ? 'error' : ''}
872
+ />
873
+ {showError && (
874
+ <span className="error-message">{errors[0].message}</span>
875
+ )}
876
+ </div>
877
+ );
878
+ }
879
+
880
+ // Usage
881
+ <FormField control={form.email} label="Email" type="email" />
882
+ <FormField control={form.age} label="Age" type="number" />
883
+ ```
884
+
885
+ ### FormField for Select
886
+
887
+ ```tsx
888
+ interface SelectFieldProps<T extends string> {
889
+ control: FieldNode<T>;
890
+ label?: string;
891
+ options: Array<{ value: T; label: string }>;
892
+ }
893
+
894
+ function SelectField<T extends string>({
895
+ control,
896
+ label,
897
+ options
898
+ }: SelectFieldProps<T>) {
899
+ const { value, errors, disabled, touched } = useFormControl(control);
900
+
901
+ return (
902
+ <div className="form-field">
903
+ {label && <label>{label}</label>}
904
+ <select
905
+ value={value}
906
+ onChange={(e) => control.setValue(e.target.value as T)}
907
+ disabled={disabled}
908
+ >
909
+ {options.map((opt) => (
910
+ <option key={opt.value} value={opt.value}>
911
+ {opt.label}
912
+ </option>
913
+ ))}
914
+ </select>
915
+ {touched && errors[0] && (
916
+ <span className="error-message">{errors[0].message}</span>
917
+ )}
918
+ </div>
919
+ );
920
+ }
921
+ ```
922
+
923
+ ### Integration with UI Libraries
924
+
925
+ ```tsx
926
+ // With shadcn/ui
927
+ import { Input } from '@/components/ui/input';
928
+ import { Label } from '@/components/ui/label';
929
+
930
+ function ShadcnFormField({ control, label }: FormFieldProps<string>) {
931
+ const { value, errors, disabled } = useFormControl(control);
932
+
933
+ return (
934
+ <div className="space-y-2">
935
+ <Label>{label}</Label>
936
+ <Input
937
+ value={value}
938
+ onChange={(e) => control.setValue(e.target.value)}
939
+ disabled={disabled}
940
+ />
941
+ {errors[0] && <p className="text-red-500">{errors[0].message}</p>}
942
+ </div>
943
+ );
944
+ }
945
+ ```
946
+
596
947
  ## 15. NON-EXISTENT API (DO NOT USE)
597
948
 
598
949
  ⚠️ **The following APIs do NOT exist in @reformer/core:**
@@ -603,6 +954,13 @@ export const creditApplicationValidation: ValidationSchemaFn<Form> = (path) => {
603
954
  | `FieldSchema` | `FieldConfig<T>` | Type for individual field config |
604
955
  | `when()` | `applyWhen()` | Conditional validation function |
605
956
  | `FormFields` | `FieldNode<T>` | Type for field nodes |
957
+ | `FormInstance<T>` | `GroupNodeWithControls<T>` | Form type for component props |
958
+ | `useArrayField()` | `form.items.push/map/removeAt` | Use ArrayNode methods directly |
959
+ | `FormProvider` | `<Component form={form} />` | Pass form via props, no context |
960
+ | `formState` | `form.valid`, `form.dirty`, etc. | Separate signals on form |
961
+ | `control` prop | Not needed | Form IS the control |
962
+ | `register('field')` | `useFormControl(form.field)` | Type-safe field access |
963
+ | `getFieldValue()` | `ctx.form.field.value.value` | Read via signals |
606
964
 
607
965
  ### Common Import Errors
608
966
 
@@ -642,3 +1000,295 @@ const schema: FormSchema<MyForm> = {
642
1000
  },
643
1001
  };
644
1002
  ```
1003
+
1004
+ ## 15.5 REACT HOOK FORM MIGRATION
1005
+
1006
+ If you're familiar with React Hook Form, here's how to translate patterns to ReFormer:
1007
+
1008
+ | React Hook Form | ReFormer | Notes |
1009
+ |-----------------|----------|-------|
1010
+ | `const { register, handleSubmit } = useForm()` | `const form = createForm({...})` | Call OUTSIDE component |
1011
+ | `register('fieldName')` | `useFormControl(form.fieldName)` | Returns control object |
1012
+ | `watch('fieldName')` | `useFormControlValue(form.fieldName)` | Returns value directly (NOT `{ value }`) |
1013
+ | `setValue('field', value)` | `form.field.setValue(value)` | Direct method call |
1014
+ | `getValues('field')` | `form.field.value.value` | Signal-based |
1015
+ | `formState.errors` | `useFormControl(form.field).errors` | Per-field errors array |
1016
+ | `formState.isValid` | `form.valid.value` | Signal |
1017
+ | `formState.isDirty` | `form.dirty.value` | Signal |
1018
+ | `handleSubmit(onSubmit)` | `form.submit(onSubmit)` | Built-in validation |
1019
+ | `<FormProvider form={...}>` | `<Component form={form} />` | Props, NOT context |
1020
+ | `useFormContext()` | Not needed | Pass form via props |
1021
+ | `useFieldArray({ name: 'items' })` | `form.items` (ArrayNode) | Direct array access |
1022
+ | `fields.map((field, index) => ...)` | `form.items.map((item, index) => ...)` | item is sub-form |
1023
+ | `append(data)` | `form.items.push(data)` | Add item |
1024
+ | `remove(index)` | `form.items.removeAt(index)` | Remove item |
1025
+ | `control` prop | Not needed | Form IS the control |
1026
+
1027
+ ### Key Differences
1028
+
1029
+ 1. **No Provider/Context** - Pass form directly via props
1030
+ ```typescript
1031
+ // ❌ React Hook Form pattern - doesn't exist in ReFormer
1032
+ <FormProvider form={form}>
1033
+ <NestedComponent />
1034
+ </FormProvider>
1035
+
1036
+ // ✅ ReFormer pattern - pass via props
1037
+ <NestedComponent form={form} />
1038
+ ```
1039
+
1040
+ 2. **Form Creation Location** - Create outside component
1041
+ ```typescript
1042
+ // ❌ WRONG - creates new form on every render
1043
+ function MyComponent() {
1044
+ const form = createForm({...});
1045
+ }
1046
+
1047
+ // ✅ CORRECT - create once, outside component
1048
+ const form = createForm({...});
1049
+ function MyComponent() {
1050
+ const ctrl = useFormControl(form.name);
1051
+ }
1052
+ ```
1053
+
1054
+ 3. **Type-Safe Field Access** - No string paths
1055
+ ```typescript
1056
+ // React Hook Form
1057
+ register('user.address.city')
1058
+
1059
+ // ReFormer - fully typed
1060
+ form.user.address.city.setValue('New York')
1061
+ ```
1062
+
1063
+ 4. **Array Items are Sub-Forms**
1064
+ ```typescript
1065
+ // React Hook Form
1066
+ fields.map((field, index) => (
1067
+ <input {...register(`items.${index}.name`)} />
1068
+ ))
1069
+
1070
+ // ReFormer - each item is a typed GroupNode
1071
+ form.items.map((item, index) => (
1072
+ <FormField control={item.name} /> // item.name is FieldNode
1073
+ ))
1074
+ ```
1075
+
1076
+ 5. **Reading Values in Behaviors**
1077
+ ```typescript
1078
+ // React Hook Form
1079
+ watch('fieldName')
1080
+
1081
+ // ReFormer in watchField callback
1082
+ watchField(path.trigger, (value, ctx) => {
1083
+ const other = ctx.form.otherField.value.value; // Signal access
1084
+ });
1085
+ ```
1086
+
1087
+ ## 16. READING FIELD VALUES (CRITICALLY IMPORTANT)
1088
+
1089
+ ### Why .value.value?
1090
+
1091
+ ReFormer uses `@preact/signals-core` for reactivity:
1092
+ - `field.value` → `Signal<T>` (reactive container)
1093
+ - `field.value.value` → `T` (actual value)
1094
+ - `field.getValue()` → `T` (shorthand method, non-reactive)
1095
+
1096
+ ```typescript
1097
+ // Reading values in different contexts:
1098
+
1099
+ // In React components - use hooks
1100
+ const { value } = useFormControl(control.email); // Object with value
1101
+ const email = useFormControlValue(control.email); // Value directly
1102
+
1103
+ // In BehaviorContext (watchField, etc.)
1104
+ watchField(path.firstName, (firstName, ctx) => {
1105
+ // ⚠️ ctx.form is typed as the PARENT GROUP of the watched field!
1106
+ // For path.nested.field: ctx.form = NestedType, NOT RootForm!
1107
+
1108
+ const lastName = ctx.form.lastName.value.value; // Read sibling field
1109
+
1110
+ // Use setFieldValue with full path for root-level fields
1111
+ ctx.setFieldValue('fullName', `${firstName} ${lastName}`);
1112
+ });
1113
+
1114
+ // Direct access on form controls
1115
+ form.email.value.value; // Read current value
1116
+ form.address.city.value.value; // Read nested value
1117
+ ```
1118
+
1119
+ ### Reading Nested Values in watchField
1120
+
1121
+ ```typescript
1122
+ // ⚠️ IMPORTANT: ctx.form type depends on the watched path!
1123
+
1124
+ // Watching root-level field
1125
+ watchField(path.loanAmount, (amount, ctx) => {
1126
+ // ctx.form is MyForm - can access all fields
1127
+ const rate = ctx.form.interestRate.value.value;
1128
+ ctx.setFieldValue('monthlyPayment', amount * rate / 12);
1129
+ });
1130
+
1131
+ // Watching nested field
1132
+ watchField(path.personalData.lastName, (lastName, ctx) => {
1133
+ // ctx.form is PersonalData, NOT MyForm!
1134
+ const firstName = ctx.form.firstName.value.value; // ✅ Works
1135
+ const middleName = ctx.form.middleName.value.value; // ✅ Works
1136
+
1137
+ // For root-level field, use setFieldValue with full path
1138
+ ctx.setFieldValue('fullName', `${lastName} ${firstName}`);
1139
+ });
1140
+ ```
1141
+
1142
+ ## 17. COMPUTE FROM vs WATCH FIELD
1143
+
1144
+ ### computeFrom - Same Nesting Level Only
1145
+
1146
+ ```typescript
1147
+ // ✅ Works: all source fields and target at same level
1148
+ computeFrom(
1149
+ [path.price, path.quantity],
1150
+ path.total,
1151
+ ({ price, quantity }) => (price || 0) * (quantity || 0)
1152
+ );
1153
+
1154
+ // ✅ Works: all nested at same level
1155
+ computeFrom(
1156
+ [path.address.houseNumber, path.address.streetName],
1157
+ path.address.fullAddress,
1158
+ ({ houseNumber, streetName }) => `${houseNumber} ${streetName}`
1159
+ );
1160
+
1161
+ // ❌ FAILS: different nesting levels
1162
+ computeFrom(
1163
+ [path.nested.price, path.nested.quantity],
1164
+ path.rootTotal, // Different level - won't work!
1165
+ ...
1166
+ );
1167
+ ```
1168
+
1169
+ ### watchField - Any Level
1170
+
1171
+ ```typescript
1172
+ // ✅ Works for cross-level computation
1173
+ watchField(path.nested.price, (price, ctx) => {
1174
+ const quantity = ctx.form.quantity.value.value; // Sibling in nested
1175
+ ctx.setFieldValue('rootTotal', price * quantity); // Full path to root
1176
+ });
1177
+
1178
+ // ✅ Works for multiple dependencies
1179
+ watchField(path.loanAmount, (amount, ctx) => {
1180
+ const term = ctx.form.loanTerm.value.value;
1181
+ const rate = ctx.form.interestRate.value.value;
1182
+
1183
+ if (amount && term && rate) {
1184
+ const monthly = calculateMonthlyPayment(amount, term, rate);
1185
+ ctx.setFieldValue('monthlyPayment', monthly);
1186
+ }
1187
+ });
1188
+ ```
1189
+
1190
+ ### Rule of Thumb
1191
+
1192
+ | Scenario | Use |
1193
+ |----------|-----|
1194
+ | All fields share same parent | `computeFrom` (simpler, auto-cleanup) |
1195
+ | Fields at different levels | `watchField` (more flexible) |
1196
+ | Multiple dependencies | `watchField` |
1197
+ | Async computation | `watchField` with async callback |
1198
+
1199
+ ## 18. ARRAY OPERATIONS
1200
+
1201
+ ### ⚠️ Array Access - CRITICAL
1202
+
1203
+ ```typescript
1204
+ // ❌ WRONG - bracket notation does NOT work!
1205
+ const first = form.items[0]; // undefined or error
1206
+ const second = form.items[1]; // undefined or error
1207
+
1208
+ // ✅ CORRECT - use .at() method
1209
+ const first = form.items.at(0); // GroupNodeWithControls<ItemType> | undefined
1210
+ const second = form.items.at(1); // GroupNodeWithControls<ItemType> | undefined
1211
+
1212
+ // ✅ CORRECT - iterate with map (most common pattern)
1213
+ form.items.map((item, index) => {
1214
+ // item is fully typed GroupNode
1215
+ item.name.setValue('New Name');
1216
+ item.price.value.value; // read value
1217
+ });
1218
+ ```
1219
+
1220
+ ### Array Methods
1221
+
1222
+ ```typescript
1223
+ // Add items
1224
+ form.items.push({ name: '', price: 0 }); // Add to end
1225
+ form.items.insert(0, { name: '', price: 0 }); // Insert at index
1226
+
1227
+ // Remove items
1228
+ form.items.removeAt(index); // Remove by index
1229
+ form.items.clear(); // Remove all items
1230
+
1231
+ // Reorder
1232
+ form.items.move(fromIndex, toIndex); // Move item
1233
+
1234
+ // Access (use .at(), NOT brackets!)
1235
+ form.items.length.value; // Current length (Signal)
1236
+ form.items.map((item, index) => ...); // Iterate items
1237
+ form.items.at(index); // Get item at index (NOT items[index]!)
1238
+ ```
1239
+
1240
+ ### Rendering Arrays
1241
+
1242
+ ```tsx
1243
+ function ItemsList({ form }: { form: GroupNodeWithControls<MyForm> }) {
1244
+ const { length } = useFormControl(form.items);
1245
+
1246
+ return (
1247
+ <div>
1248
+ {form.items.map((item, index) => (
1249
+ // item is GroupNode (sub-form) - each field is a control
1250
+ <div key={item.id || index}>
1251
+ <FormField control={item.name} />
1252
+ <FormField control={item.price} />
1253
+ <button onClick={() => form.items.removeAt(index)}>Remove</button>
1254
+ </div>
1255
+ ))}
1256
+
1257
+ {length === 0 && <p>No items yet</p>}
1258
+
1259
+ <button onClick={() => form.items.push({ name: '', price: 0 })}>
1260
+ Add Item
1261
+ </button>
1262
+ </div>
1263
+ );
1264
+ }
1265
+ ```
1266
+
1267
+ ### Array Cross-Validation
1268
+
1269
+ ```typescript
1270
+ // Validate uniqueness across array items
1271
+ validateTree((ctx: { form: MyForm }) => {
1272
+ const items = ctx.form.items;
1273
+ const names = items.map(item => item.name.value.value);
1274
+ const uniqueNames = new Set(names);
1275
+
1276
+ if (names.length !== uniqueNames.size) {
1277
+ return { code: 'duplicate', message: 'Item names must be unique' };
1278
+ }
1279
+ return null;
1280
+ }, { targetField: 'items' });
1281
+
1282
+ // Validate sum of percentages
1283
+ validateTree((ctx: { form: MyForm }) => {
1284
+ const items = ctx.form.items;
1285
+ const totalPercent = items.reduce(
1286
+ (sum, item) => sum + (item.percentage.value.value || 0),
1287
+ 0
1288
+ );
1289
+
1290
+ if (Math.abs(totalPercent - 100) > 0.01) {
1291
+ return { code: 'invalid_total', message: 'Percentages must sum to 100%' };
1292
+ }
1293
+ return null;
1294
+ }, { targetField: 'items' });