@reformer/core 1.1.0-beta.8 → 2.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.
package/llms.txt CHANGED
@@ -1,6 +1,6 @@
1
1
  # ReFormer - LLM Integration Guide
2
2
 
3
- ## 1. QUICK REFERENCE
3
+ ## 1. API Reference
4
4
 
5
5
  ### Imports (CRITICALLY IMPORTANT)
6
6
 
@@ -30,16 +30,16 @@
30
30
  | `useFormControl(field)` | `{ value, errors, disabled, touched, ... }` | All signals | Full field state, form inputs |
31
31
  | `useFormControlValue(field)` | `T` (value directly) | Only value signal | Conditional rendering |
32
32
 
33
- ⚠️ **CRITICAL**: Do NOT destructure `useFormControlValue`! It returns `T` directly, NOT `{ value: T }`.
33
+ **CRITICAL**: Do NOT destructure `useFormControlValue`! It returns `T` directly, NOT `{ value: T }`.
34
34
 
35
35
  ```typescript
36
- // WRONG - will always be undefined!
36
+ // WRONG - will always be undefined!
37
37
  const { value: loanType } = useFormControlValue(control.loanType);
38
38
 
39
- // CORRECT
39
+ // CORRECT
40
40
  const loanType = useFormControlValue(control.loanType);
41
41
 
42
- // CORRECT - useFormControl returns object, destructuring OK
42
+ // CORRECT - useFormControl returns object, destructuring OK
43
43
  const { value, errors, disabled } = useFormControl(control.loanType);
44
44
  ```
45
45
 
@@ -143,7 +143,7 @@ validateTree(validator: (ctx) => ValidationError | null, options?: { targetField
143
143
  // Conditional validation (3 arguments!)
144
144
  applyWhen(fieldPath, condition: (fieldValue) => boolean, validatorsFn: (path) => void)
145
145
 
146
- // ⚠️ applyWhen Examples - CRITICALLY IMPORTANT
146
+ // applyWhen Examples - CRITICALLY IMPORTANT
147
147
  // applyWhen takes 3 arguments: triggerField, condition, validators
148
148
 
149
149
  // Example 1: Simple condition
@@ -176,7 +176,7 @@ applyWhen(
176
176
  }
177
177
  );
178
178
 
179
- // WRONG - only 2 arguments (React Hook Form pattern)
179
+ // WRONG - only 2 arguments (React Hook Form pattern)
180
180
  applyWhen(
181
181
  (form) => form.loanType === 'mortgage', // WRONG!
182
182
  () => { required(path.propertyValue); }
@@ -197,8 +197,8 @@ disableWhen(path, condition: (form) => boolean)
197
197
  // Computed fields (same nesting level)
198
198
  computeFrom(sourcePaths[], targetPath, compute: (values) => result, options?: { debounce?: number; condition?: (form) => boolean })
199
199
 
200
- // Watch field changes
201
- watchField(path, callback: (value, ctx: BehaviorContext) => void, options?: { immediate?: boolean; debounce?: number })
200
+ // Watch field changes (ALWAYS use { immediate: false } to prevent cycle detection!)
201
+ watchField(path, callback: (value, ctx: BehaviorContext) => void, options: { immediate: false; debounce?: number })
202
202
 
203
203
  // Copy values between fields
204
204
  copyFrom(sourcePath, targetPath, options?: { when?: (form) => boolean; fields?: string[]; transform?: (value) => value })
@@ -206,8 +206,8 @@ copyFrom(sourcePath, targetPath, options?: { when?: (form) => boolean; fields?:
206
206
  // Reset field when condition met
207
207
  resetWhen(path, condition: (form) => boolean, options?: { toValue?: any })
208
208
 
209
- // Re-validate when another field changes
210
- revalidateWhen(triggerPath, targetPath)
209
+ // Re-validate target field when any trigger changes
210
+ revalidateWhen(targetPath, triggerPaths[], options?: { debounce?: number })
211
211
 
212
212
  // Sync multiple fields
213
213
  syncFields(paths[], options?: { bidirectional?: boolean })
@@ -220,7 +220,7 @@ transformers.trim, transformers.toUpperCase, transformers.toLowerCase, transform
220
220
  interface BehaviorContext<TForm> {
221
221
  form: GroupNodeWithControls<TForm>; // Form proxy with typed field access
222
222
  setFieldValue: (path: string, value: any) => void;
223
- // ⚠️ To READ field values, use: ctx.form.fieldName.value.value
223
+ // To READ field values, use: ctx.form.fieldName.value.value
224
224
  }
225
225
  ```
226
226
 
@@ -241,7 +241,7 @@ enableWhen(path.mortgageFields, (form) => form.loanType === 'mortgage', {
241
241
  // Use watchField instead:
242
242
  watchField(path.nested.field, (value, ctx) => {
243
243
  ctx.setFieldValue('rootField', computedValue);
244
- });
244
+ }, { immediate: false });
245
245
  ```
246
246
 
247
247
  ### Type-Safe useFormControl
@@ -250,31 +250,60 @@ watchField(path.nested.field, (value, ctx) => {
250
250
  const { value } = useFormControl(form.field as FieldNode<ExpectedType>);
251
251
  ```
252
252
 
253
- ## 4. ⚠️ COMMON MISTAKES
253
+ ### Validation Priority (IMPORTANT)
254
+
255
+ **Always prefer built-in validators over custom ones:**
256
+
257
+ ```typescript
258
+ // 1. BEST: Use built-in validators when available
259
+ required(path.email);
260
+ email(path.email);
261
+ min(path.age, 18);
262
+ minLength(path.password, 8);
263
+ pattern(path.phone, /^\+7\d{10}$/);
264
+
265
+ // 2. GOOD: Use validate() only when no built-in validator exists
266
+ validate(path.customField, (value, ctx) => {
267
+ // Custom logic that can't be expressed with built-in validators
268
+ if (customCondition(value)) {
269
+ return { code: 'custom', message: 'Custom error' };
270
+ }
271
+ return null;
272
+ });
273
+
274
+ // 3. WRONG: Don't recreate built-in validators
275
+ validate(path.email, (value) => {
276
+ if (!value) return { code: 'required', message: 'Required' }; // Use required() instead!
277
+ if (!value.includes('@')) return { code: 'email', message: 'Invalid' }; // Use email() instead!
278
+ return null;
279
+ });
280
+ ```
281
+
282
+ ## 4. COMMON MISTAKES
254
283
 
255
284
  ### useFormControlValue (CRITICAL)
256
285
 
257
286
  ```typescript
258
- // WRONG - useFormControlValue returns T directly, NOT { value: T }
287
+ // WRONG - useFormControlValue returns T directly, NOT { value: T }
259
288
  const { value: loanType } = useFormControlValue(control.loanType);
260
289
  // Result: loanType is ALWAYS undefined! Conditional rendering will fail.
261
290
 
262
- // CORRECT
291
+ // CORRECT
263
292
  const loanType = useFormControlValue(control.loanType);
264
293
 
265
- // ALSO CORRECT - useFormControl returns object
294
+ // ALSO CORRECT - useFormControl returns object
266
295
  const { value, errors } = useFormControl(control.loanType);
267
296
  ```
268
297
 
269
298
  ### Reading Field Values in BehaviorContext (CRITICAL)
270
299
 
271
300
  ```typescript
272
- // WRONG - getFieldValue does NOT exist!
301
+ // WRONG - getFieldValue does NOT exist!
273
302
  watchField(path.amount, (amount, ctx) => {
274
303
  const rate = ctx.getFieldValue('rate'); // ERROR: Property 'getFieldValue' does not exist
275
304
  });
276
305
 
277
- // CORRECT - use ctx.form.fieldName.value.value
306
+ // CORRECT - use ctx.form.fieldName.value.value
278
307
  watchField(path.amount, (amount, ctx) => {
279
308
  const rate = ctx.form.rate.value.value; // Read via signal
280
309
  ctx.setFieldValue('total', amount * rate);
@@ -284,21 +313,21 @@ watchField(path.amount, (amount, ctx) => {
284
313
  ### Validators
285
314
 
286
315
  ```typescript
287
- // WRONG
316
+ // WRONG
288
317
  required(path.email, 'Email is required');
289
318
 
290
- // CORRECT
319
+ // CORRECT
291
320
  required(path.email, { message: 'Email is required' });
292
321
  ```
293
322
 
294
323
  ### Types
295
324
 
296
325
  ```typescript
297
- // WRONG
326
+ // WRONG
298
327
  amount: number | null;
299
328
  [key: string]: unknown;
300
329
 
301
- // CORRECT
330
+ // CORRECT
302
331
  amount: number | undefined;
303
332
  // No index signature
304
333
  ```
@@ -306,10 +335,10 @@ amount: number | undefined;
306
335
  ### computeFrom
307
336
 
308
337
  ```typescript
309
- // WRONG - different nesting levels
338
+ // WRONG - different nesting levels
310
339
  computeFrom([path.nested.a, path.nested.b], path.root, ...)
311
340
 
312
- // CORRECT - use watchField
341
+ // CORRECT - use watchField
313
342
  watchField(path.nested.a, (_, ctx) => {
314
343
  ctx.setFieldValue('root', computed);
315
344
  });
@@ -318,10 +347,10 @@ watchField(path.nested.a, (_, ctx) => {
318
347
  ### Imports
319
348
 
320
349
  ```typescript
321
- // WRONG - types are not in submodules
350
+ // WRONG - types are not in submodules
322
351
  import { ValidationSchemaFn } from '@reformer/core/validators';
323
352
 
324
- // CORRECT - types from main module
353
+ // CORRECT - types from main module
325
354
  import type { ValidationSchemaFn } from '@reformer/core';
326
355
  import { required, email } from '@reformer/core/validators';
327
356
  ```
@@ -335,6 +364,7 @@ import { required, email } from '@reformer/core/validators';
335
364
  | `FormFields[]` instead of concrete type | Type inference issue | Use `as FieldNode<T>` |
336
365
  | `Type 'X' is missing properties from type 'Y'` | Cross-level computeFrom | Use watchField instead |
337
366
  | `Module has no exported member` | Wrong import source | Types from core, functions from submodules |
367
+ | `Cycle detected` | Multiple watchers on same field calling disable/setValue | See 22-cycle-detection.md |
338
368
 
339
369
  ## 6. COMPLETE IMPORT EXAMPLE
340
370
 
@@ -361,7 +391,7 @@ import { computeFrom, enableWhen, watchField, copyFrom } from '@reformer/core/be
361
391
  ## 7. FORM TYPE DEFINITION
362
392
 
363
393
  ```typescript
364
- // CORRECT form type definition
394
+ // CORRECT form type definition
365
395
  interface MyForm {
366
396
  // Required fields
367
397
  name: string;
@@ -390,7 +420,7 @@ interface MyForm {
390
420
 
391
421
  ## 8. FORMSCHEMA FORMAT (CRITICALLY IMPORTANT)
392
422
 
393
- ⚠️ **Every field MUST have `value` and `component` properties!**
423
+ **Every field MUST have `value` and `component` properties!**
394
424
 
395
425
  ### FieldConfig Interface
396
426
 
@@ -477,13 +507,13 @@ const schema: FormSchema<MyForm> = {
477
507
  };
478
508
  ```
479
509
 
480
- ### WRONG - This will NOT compile
510
+ ### WRONG - This will NOT compile
481
511
 
482
512
  ```typescript
483
513
  // Missing value and component - TypeScript will error!
484
514
  const schema = {
485
- name: '', // Wrong
486
- email: '', // Wrong
515
+ name: '', // Wrong
516
+ email: '', // Wrong
487
517
  };
488
518
  ```
489
519
 
@@ -503,7 +533,7 @@ form.address.city.value.value; // Get current value
503
533
  form.items.push({ id: '1', name: 'Item' }); // Array operations
504
534
  ```
505
535
 
506
- ### ⚠️ createForm Returns a Proxy
536
+ ### createForm Returns a Proxy
507
537
 
508
538
  ```typescript
509
539
  // createForm() returns GroupNodeWithControls<T> (a Proxy wrapper around GroupNode)
@@ -514,12 +544,12 @@ form.email // FieldNode<string> - TypeScript knows the type!
514
544
  form.address.city // FieldNode<string> - nested access works
515
545
  form.items.at(0) // GroupNodeWithControls<ItemType> - array items
516
546
 
517
- // ⚠️ IMPORTANT: Proxy doesn't pass instanceof checks!
547
+ // IMPORTANT: Proxy doesn't pass instanceof checks!
518
548
  // Use type guards instead:
519
549
  import { isFieldNode, isGroupNode, isArrayNode } from '@reformer/core';
520
550
 
521
- if (isFieldNode(node)) { /* ... */ } // Works with Proxy
522
- if (node instanceof FieldNode) { /* ... */ } // Fails with Proxy!
551
+ if (isFieldNode(node)) { /* ... */ } // Works with Proxy
552
+ if (node instanceof FieldNode) { /* ... */ } // Fails with Proxy!
523
553
  ```
524
554
 
525
555
  ## 9. ARRAY SCHEMA FORMAT
@@ -527,7 +557,7 @@ if (node instanceof FieldNode) { /* ... */ } // ❌ Fails with Proxy!
527
557
  **Array items are sub-forms!** Each array element is a complete sub-form with its own fields, validation, and behavior.
528
558
 
529
559
  ```typescript
530
- // CORRECT - use tuple format for arrays
560
+ // CORRECT - use tuple format for arrays
531
561
  // The template item defines the sub-form schema for each array element
532
562
  const itemSchema = {
533
563
  id: { value: '', component: Input },
@@ -548,7 +578,7 @@ form.items.map((item) => {
548
578
  ```
549
579
 
550
580
  ```typescript
551
- // WRONG - object format is NOT supported
581
+ // WRONG - object format is NOT supported
552
582
  const schema = {
553
583
  items: { schema: itemSchema, initialItems: [] }, // This will NOT work
554
584
  };
@@ -578,7 +608,7 @@ validateItems(path.items, (itemPath) => {
578
608
  ## 10. ASYNC WATCHFIELD (CRITICALLY IMPORTANT)
579
609
 
580
610
  ```typescript
581
- // CORRECT - async watchField with ALL safeguards
611
+ // CORRECT - async watchField with ALL safeguards
582
612
  watchField(
583
613
  path.parentField,
584
614
  async (value, ctx) => {
@@ -595,7 +625,7 @@ watchField(
595
625
  { immediate: false, debounce: 300 } // REQUIRED options
596
626
  );
597
627
 
598
- // WRONG - missing safeguards
628
+ // WRONG - missing safeguards
599
629
  watchField(path.field, async (value, ctx) => {
600
630
  const { data } = await fetchData(value); // Will fail silently!
601
631
  });
@@ -610,7 +640,7 @@ watchField(path.field, async (value, ctx) => {
610
640
  ## 11. ARRAY CLEANUP PATTERN
611
641
 
612
642
  ```typescript
613
- // CORRECT - cleanup array when checkbox unchecked
643
+ // CORRECT - cleanup array when checkbox unchecked
614
644
  watchField(
615
645
  path.hasItems,
616
646
  (hasItems, ctx) => {
@@ -621,7 +651,7 @@ watchField(
621
651
  { immediate: false }
622
652
  );
623
653
 
624
- // WRONG - no immediate: false, no null check
654
+ // WRONG - no immediate: false, no null check
625
655
  watchField(path.hasItems, (hasItems, ctx) => {
626
656
  if (!hasItems) ctx.form.items.clear(); // May crash on init!
627
657
  });
@@ -709,17 +739,17 @@ function MultiStepForm() {
709
739
  }
710
740
  ```
711
741
 
712
- ## 13. ⚠️ EXTENDED COMMON MISTAKES
742
+ ## 13. EXTENDED COMMON MISTAKES
713
743
 
714
744
  ### Behavior Composition (Cycle Error)
715
745
 
716
746
  ```typescript
717
- // WRONG - apply() in behavior causes "Cycle detected"
747
+ // WRONG - apply() in behavior causes "Cycle detected"
718
748
  const mainBehavior: BehaviorSchemaFn<Form> = (path) => {
719
749
  apply(addressBehavior, path.address); // WILL FAIL!
720
750
  };
721
751
 
722
- // CORRECT - inline or use setup function
752
+ // CORRECT - inline or use setup function
723
753
  const setupAddressBehavior = (path: FieldPath<Address>) => {
724
754
  watchField(path.region, async (region, ctx) => {
725
755
  // ...
@@ -734,12 +764,12 @@ const mainBehavior: BehaviorSchemaFn<Form> = (path) => {
734
764
  ### Infinite Loop in watchField
735
765
 
736
766
  ```typescript
737
- // WRONG - causes infinite loop
767
+ // WRONG - causes infinite loop
738
768
  watchField(path.field, (value, ctx) => {
739
769
  ctx.form.field.setValue(value.toUpperCase()); // Loop!
740
770
  });
741
771
 
742
- // CORRECT - write to different field OR add guard
772
+ // CORRECT - write to different field OR add guard
743
773
  watchField(path.input, (value, ctx) => {
744
774
  const upper = value?.toUpperCase() || '';
745
775
  if (ctx.form.display.value.value !== upper) {
@@ -748,13 +778,53 @@ watchField(path.input, (value, ctx) => {
748
778
  }, { immediate: false });
749
779
  ```
750
780
 
781
+ ### Multiple Watchers on Same Field (Cycle Error)
782
+
783
+ ```typescript
784
+ // WRONG - multiple watchers on insuranceType + missing { immediate: false }
785
+ watchField(path.insuranceType, (_, ctx) => {
786
+ ctx.form.vehicle.vin.disable();
787
+ ctx.form.vehicle.vin.setValue('');
788
+ }); // NO OPTIONS - BAD!
789
+ watchField(path.insuranceType, (_, ctx) => {
790
+ ctx.form.property.type.disable(); // CYCLE!
791
+ }); // NO OPTIONS - BAD!
792
+
793
+ // CORRECT - consolidate into ONE watcher with guards AND { immediate: false }
794
+ watchField(path.insuranceType, (_, ctx) => {
795
+ const type = ctx.form.insuranceType.value.value;
796
+ const isVehicle = type === 'casco';
797
+
798
+ // Guard: only disable if not already disabled
799
+ if (!isVehicle && !ctx.form.vehicle.vin.disabled.value) {
800
+ ctx.form.vehicle.vin.disable();
801
+ }
802
+ // Guard: only setValue if value differs
803
+ if (!isVehicle && ctx.form.vehicle.vin.getValue() !== '') {
804
+ ctx.form.vehicle.vin.setValue('');
805
+ }
806
+ // Arrays: compare by length, not reference
807
+ if (!isVehicle) {
808
+ const drivers = ctx.form.drivers.getValue();
809
+ if (Array.isArray(drivers) && drivers.length > 0) {
810
+ ctx.form.drivers.setValue([]);
811
+ }
812
+ }
813
+ }, { immediate: false }); // REQUIRED!
814
+
815
+ // BEST - use enableWhen instead of watchField
816
+ enableWhen(path.vehicle.vin, (form) => form.insuranceType === 'casco', { resetOnDisable: true });
817
+ ```
818
+
819
+ See `22-cycle-detection.md` for complete pattern.
820
+
751
821
  ### validateTree Typing
752
822
 
753
823
  ```typescript
754
- // WRONG - implicit any
824
+ // WRONG - implicit any
755
825
  validateTree((ctx) => { ... });
756
826
 
757
- // CORRECT - explicit typing
827
+ // CORRECT - explicit typing
758
828
  validateTree((ctx: { form: MyForm }) => {
759
829
  if (ctx.form.field1 > ctx.form.field2) {
760
830
  return { code: 'error', message: 'Invalid' };
@@ -891,11 +961,7 @@ interface SelectFieldProps<T extends string> {
891
961
  options: Array<{ value: T; label: string }>;
892
962
  }
893
963
 
894
- function SelectField<T extends string>({
895
- control,
896
- label,
897
- options
898
- }: SelectFieldProps<T>) {
964
+ function SelectField<T extends string>({ control, label, options }: SelectFieldProps<T>) {
899
965
  const { value, errors, disabled, touched } = useFormControl(control);
900
966
 
901
967
  return (
@@ -912,9 +978,7 @@ function SelectField<T extends string>({
912
978
  </option>
913
979
  ))}
914
980
  </select>
915
- {touched && errors[0] && (
916
- <span className="error-message">{errors[0].message}</span>
917
- )}
981
+ {touched && errors[0] && <span className="error-message">{errors[0].message}</span>}
918
982
  </div>
919
983
  );
920
984
  }
@@ -933,11 +997,7 @@ function ShadcnFormField({ control, label }: FormFieldProps<string>) {
933
997
  return (
934
998
  <div className="space-y-2">
935
999
  <Label>{label}</Label>
936
- <Input
937
- value={value}
938
- onChange={(e) => control.setValue(e.target.value)}
939
- disabled={disabled}
940
- />
1000
+ <Input value={value} onChange={(e) => control.setValue(e.target.value)} disabled={disabled} />
941
1001
  {errors[0] && <p className="text-red-500">{errors[0].message}</p>}
942
1002
  </div>
943
1003
  );
@@ -946,9 +1006,9 @@ function ShadcnFormField({ control, label }: FormFieldProps<string>) {
946
1006
 
947
1007
  ## 15. NON-EXISTENT API (DO NOT USE)
948
1008
 
949
- ⚠️ **The following APIs do NOT exist in @reformer/core:**
1009
+ **The following APIs do NOT exist in @reformer/core:**
950
1010
 
951
- | Wrong | Correct | Notes |
1011
+ | Wrong | Correct | Notes |
952
1012
  |----------|-----------|-------|
953
1013
  | `useForm` | `createForm` | There is no useForm hook |
954
1014
  | `FieldSchema` | `FieldConfig<T>` | Type for individual field config |
@@ -965,13 +1025,13 @@ function ShadcnFormField({ control, label }: FormFieldProps<string>) {
965
1025
  ### Common Import Errors
966
1026
 
967
1027
  ```typescript
968
- // WRONG - These do NOT exist
1028
+ // WRONG - These do NOT exist
969
1029
  import { useForm } from '@reformer/core'; // NO!
970
1030
  import { when } from '@reformer/core/validators'; // NO!
971
1031
  import type { FieldSchema } from '@reformer/core'; // NO!
972
1032
  import type { FormFields } from '@reformer/core'; // NO!
973
1033
 
974
- // CORRECT
1034
+ // CORRECT
975
1035
  import { createForm, useFormControl } from '@reformer/core';
976
1036
  import { applyWhen } from '@reformer/core/validators';
977
1037
  import type { FieldConfig, FieldNode } from '@reformer/core';
@@ -980,13 +1040,13 @@ import type { FieldConfig, FieldNode } from '@reformer/core';
980
1040
  ### FormSchema Common Mistakes
981
1041
 
982
1042
  ```typescript
983
- // WRONG - Simple values don't work
1043
+ // WRONG - Simple values don't work
984
1044
  const schema = {
985
1045
  name: '', // Missing { value, component }
986
1046
  email: '', // Missing { value, component }
987
1047
  };
988
1048
 
989
- // CORRECT - Every field needs value and component
1049
+ // CORRECT - Every field needs value and component
990
1050
  const schema: FormSchema<MyForm> = {
991
1051
  name: {
992
1052
  value: '',
@@ -1001,97 +1061,14 @@ const schema: FormSchema<MyForm> = {
1001
1061
  };
1002
1062
  ```
1003
1063
 
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
1064
  ## 16. READING FIELD VALUES (CRITICALLY IMPORTANT)
1088
1065
 
1089
1066
  ### Why .value.value?
1090
1067
 
1091
1068
  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)
1069
+ - `field.value` -> `Signal<T>` (reactive container)
1070
+ - `field.value.value` -> `T` (actual value)
1071
+ - `field.getValue()` -> `T` (shorthand method, non-reactive)
1095
1072
 
1096
1073
  ```typescript
1097
1074
  // Reading values in different contexts:
@@ -1102,14 +1079,14 @@ const email = useFormControlValue(control.email); // Value directly
1102
1079
 
1103
1080
  // In BehaviorContext (watchField, etc.)
1104
1081
  watchField(path.firstName, (firstName, ctx) => {
1105
- // ⚠️ ctx.form is typed as the PARENT GROUP of the watched field!
1082
+ // ctx.form is typed as the PARENT GROUP of the watched field!
1106
1083
  // For path.nested.field: ctx.form = NestedType, NOT RootForm!
1107
1084
 
1108
1085
  const lastName = ctx.form.lastName.value.value; // Read sibling field
1109
1086
 
1110
1087
  // Use setFieldValue with full path for root-level fields
1111
1088
  ctx.setFieldValue('fullName', `${firstName} ${lastName}`);
1112
- });
1089
+ }, { immediate: false }); // REQUIRED to prevent cycle detection!
1113
1090
 
1114
1091
  // Direct access on form controls
1115
1092
  form.email.value.value; // Read current value
@@ -1119,24 +1096,24 @@ form.address.city.value.value; // Read nested value
1119
1096
  ### Reading Nested Values in watchField
1120
1097
 
1121
1098
  ```typescript
1122
- // ⚠️ IMPORTANT: ctx.form type depends on the watched path!
1099
+ // IMPORTANT: ctx.form type depends on the watched path!
1123
1100
 
1124
1101
  // Watching root-level field
1125
1102
  watchField(path.loanAmount, (amount, ctx) => {
1126
1103
  // ctx.form is MyForm - can access all fields
1127
1104
  const rate = ctx.form.interestRate.value.value;
1128
1105
  ctx.setFieldValue('monthlyPayment', amount * rate / 12);
1129
- });
1106
+ }, { immediate: false });
1130
1107
 
1131
1108
  // Watching nested field
1132
1109
  watchField(path.personalData.lastName, (lastName, ctx) => {
1133
1110
  // ctx.form is PersonalData, NOT MyForm!
1134
- const firstName = ctx.form.firstName.value.value; // Works
1135
- const middleName = ctx.form.middleName.value.value; // Works
1111
+ const firstName = ctx.form.firstName.value.value; // Works
1112
+ const middleName = ctx.form.middleName.value.value; // Works
1136
1113
 
1137
1114
  // For root-level field, use setFieldValue with full path
1138
1115
  ctx.setFieldValue('fullName', `${lastName} ${firstName}`);
1139
- });
1116
+ }, { immediate: false });
1140
1117
  ```
1141
1118
 
1142
1119
  ## 17. COMPUTE FROM vs WATCH FIELD
@@ -1144,21 +1121,21 @@ watchField(path.personalData.lastName, (lastName, ctx) => {
1144
1121
  ### computeFrom - Same Nesting Level Only
1145
1122
 
1146
1123
  ```typescript
1147
- // Works: all source fields and target at same level
1124
+ // Works: all source fields and target at same level
1148
1125
  computeFrom(
1149
1126
  [path.price, path.quantity],
1150
1127
  path.total,
1151
1128
  ({ price, quantity }) => (price || 0) * (quantity || 0)
1152
1129
  );
1153
1130
 
1154
- // Works: all nested at same level
1131
+ // Works: all nested at same level
1155
1132
  computeFrom(
1156
1133
  [path.address.houseNumber, path.address.streetName],
1157
1134
  path.address.fullAddress,
1158
1135
  ({ houseNumber, streetName }) => `${houseNumber} ${streetName}`
1159
1136
  );
1160
1137
 
1161
- // FAILS: different nesting levels
1138
+ // FAILS: different nesting levels
1162
1139
  computeFrom(
1163
1140
  [path.nested.price, path.nested.quantity],
1164
1141
  path.rootTotal, // Different level - won't work!
@@ -1169,13 +1146,13 @@ computeFrom(
1169
1146
  ### watchField - Any Level
1170
1147
 
1171
1148
  ```typescript
1172
- // Works for cross-level computation
1149
+ // Works for cross-level computation
1173
1150
  watchField(path.nested.price, (price, ctx) => {
1174
1151
  const quantity = ctx.form.quantity.value.value; // Sibling in nested
1175
1152
  ctx.setFieldValue('rootTotal', price * quantity); // Full path to root
1176
- });
1153
+ }, { immediate: false }); // REQUIRED!
1177
1154
 
1178
- // Works for multiple dependencies
1155
+ // Works for multiple dependencies
1179
1156
  watchField(path.loanAmount, (amount, ctx) => {
1180
1157
  const term = ctx.form.loanTerm.value.value;
1181
1158
  const rate = ctx.form.interestRate.value.value;
@@ -1184,7 +1161,7 @@ watchField(path.loanAmount, (amount, ctx) => {
1184
1161
  const monthly = calculateMonthlyPayment(amount, term, rate);
1185
1162
  ctx.setFieldValue('monthlyPayment', monthly);
1186
1163
  }
1187
- });
1164
+ }, { immediate: false }); // REQUIRED!
1188
1165
  ```
1189
1166
 
1190
1167
  ### Rule of Thumb
@@ -1198,18 +1175,18 @@ watchField(path.loanAmount, (amount, ctx) => {
1198
1175
 
1199
1176
  ## 18. ARRAY OPERATIONS
1200
1177
 
1201
- ### ⚠️ Array Access - CRITICAL
1178
+ ### Array Access - CRITICAL
1202
1179
 
1203
1180
  ```typescript
1204
- // WRONG - bracket notation does NOT work!
1181
+ // WRONG - bracket notation does NOT work!
1205
1182
  const first = form.items[0]; // undefined or error
1206
1183
  const second = form.items[1]; // undefined or error
1207
1184
 
1208
- // CORRECT - use .at() method
1185
+ // CORRECT - use .at() method
1209
1186
  const first = form.items.at(0); // GroupNodeWithControls<ItemType> | undefined
1210
1187
  const second = form.items.at(1); // GroupNodeWithControls<ItemType> | undefined
1211
1188
 
1212
- // CORRECT - iterate with map (most common pattern)
1189
+ // CORRECT - iterate with map (most common pattern)
1213
1190
  form.items.map((item, index) => {
1214
1191
  // item is fully typed GroupNode
1215
1192
  item.name.setValue('New Name');
@@ -1292,3 +1269,171 @@ validateTree((ctx: { form: MyForm }) => {
1292
1269
  }
1293
1270
  return null;
1294
1271
  }, { targetField: 'items' });
1272
+ ```
1273
+
1274
+ ## Cycle Detection Prevention Checklist
1275
+
1276
+ **ALWAYS follow these rules to prevent "Cycle detected" error:**
1277
+
1278
+ 1. ✅ **ONE watchField per trigger field** - consolidate all logic into single handler
1279
+ 2. ✅ **ALWAYS use `{ immediate: false }`** - required option for watchField
1280
+ 3. ✅ **Guard all disable/enable calls** - check `field.disabled.value` before calling
1281
+ 4. ✅ **Guard all setValue calls** - only call if value actually differs
1282
+ 5. ✅ **Arrays: compare by length** - `[] !== []` is always true, use `.length`
1283
+ 6. ✅ **Prefer enableWhen over watchField** - for simple enable/disable logic
1284
+
1285
+ ---
1286
+
1287
+ ## Cycle Detected Error
1288
+
1289
+ ### Problem
1290
+
1291
+ Error `Cycle detected` occurs when reactive system detects circular dependency during field updates.
1292
+
1293
+ ### Root Cause
1294
+
1295
+ Multiple `watchField` handlers on the same field (e.g., `path.insuranceType`) each calling `disable()` and `setValue()` creates reactive cycles:
1296
+
1297
+ ```typescript
1298
+ // WRONG - Multiple watchers on same field + missing { immediate: false }
1299
+ watchField(path.insuranceType, (_, ctx) => {
1300
+ // Handler 1: vehicle fields
1301
+ if (!isVehicle) {
1302
+ ctx.form.vehicle.vin.disable();
1303
+ ctx.form.vehicle.vin.setValue('');
1304
+ }
1305
+ }); // NO OPTIONS - BAD!
1306
+
1307
+ watchField(path.insuranceType, (_, ctx) => {
1308
+ // Handler 2: property fields - CAUSES CYCLE!
1309
+ if (!isProperty) {
1310
+ ctx.form.property.type.disable();
1311
+ ctx.form.property.type.setValue('');
1312
+ }
1313
+ }); // NO OPTIONS - BAD!
1314
+
1315
+ // More watchers on same field = more cycles
1316
+ ```
1317
+
1318
+ ### Solution
1319
+
1320
+ 1. **Consolidate all watchers for same field into ONE handler**
1321
+ 2. **Check state before calling disable/enable/setValue**
1322
+ 3. **ALWAYS add `{ immediate: false }` option**
1323
+
1324
+ ```typescript
1325
+ // CORRECT - Single consolidated watcher with guards AND { immediate: false }
1326
+ watchField(path.insuranceType, (_value, ctx) => {
1327
+ const insuranceType = ctx.form.insuranceType.value.value;
1328
+ const isVehicle = insuranceType === 'casco' || insuranceType === 'osago';
1329
+ const isProperty = insuranceType === 'property';
1330
+
1331
+ // Helper: check if array value needs update (compare by length, not reference)
1332
+ const needsValueUpdate = <T>(current: T, defaultVal: T): boolean => {
1333
+ if (Array.isArray(current) && Array.isArray(defaultVal)) {
1334
+ return current.length !== defaultVal.length;
1335
+ }
1336
+ return current !== defaultVal;
1337
+ };
1338
+
1339
+ // Helper: disable only if not already disabled, setValue only if different
1340
+ const disableAndReset = <T>(
1341
+ field: { disable: () => void; setValue: (v: T) => void; getValue: () => T; disabled: { value: boolean } } | undefined,
1342
+ defaultValue: T
1343
+ ) => {
1344
+ if (field) {
1345
+ if (!field.disabled.value) {
1346
+ field.disable();
1347
+ }
1348
+ if (needsValueUpdate(field.getValue(), defaultValue)) {
1349
+ field.setValue(defaultValue);
1350
+ }
1351
+ }
1352
+ };
1353
+
1354
+ const enableField = (field: { enable: () => void; disabled: { value: boolean } } | undefined) => {
1355
+ if (field && field.disabled.value) {
1356
+ field.enable();
1357
+ }
1358
+ };
1359
+
1360
+ // --- All vehicle fields in one place ---
1361
+ if (isVehicle) {
1362
+ enableField(ctx.form.vehicle.vin);
1363
+ enableField(ctx.form.vehicle.brand);
1364
+ } else {
1365
+ disableAndReset(ctx.form.vehicle.vin, '');
1366
+ disableAndReset(ctx.form.vehicle.brand, '');
1367
+ }
1368
+
1369
+ // --- All property fields in one place ---
1370
+ if (isProperty) {
1371
+ enableField(ctx.form.property.type);
1372
+ } else {
1373
+ disableAndReset(ctx.form.property.type, '');
1374
+ }
1375
+
1376
+ // --- Arrays: compare by length ---
1377
+ if (isVehicle) {
1378
+ enableField(ctx.form.drivers);
1379
+ } else {
1380
+ disableAndReset(ctx.form.drivers, []); // Won't call setValue if already empty
1381
+ }
1382
+ }, { immediate: false }); // REQUIRED!
1383
+ ```
1384
+
1385
+ ### Prefer Built-in Behaviors
1386
+
1387
+ **Instead of complex watchField with guards, use built-in behaviors when possible:**
1388
+
1389
+ ```typescript
1390
+ // ❌ COMPLEX - watchField with manual guards (error-prone)
1391
+ watchField(path.insuranceType, (_value, ctx) => {
1392
+ const isVehicle = ctx.form.insuranceType.value.value === 'casco';
1393
+ if (isVehicle) {
1394
+ if (ctx.form.vehicle.vin.disabled.value) ctx.form.vehicle.vin.enable();
1395
+ } else {
1396
+ if (!ctx.form.vehicle.vin.disabled.value) ctx.form.vehicle.vin.disable();
1397
+ if (ctx.form.vehicle.vin.getValue() !== '') ctx.form.vehicle.vin.setValue('');
1398
+ }
1399
+ }, { immediate: false });
1400
+
1401
+ // ✅ SIMPLE - enableWhen with resetOnDisable (recommended)
1402
+ enableWhen(path.vehicle.vin, (form) => form.insuranceType === 'casco', { resetOnDisable: true });
1403
+ enableWhen(path.vehicle.brand, (form) => form.insuranceType === 'casco', { resetOnDisable: true });
1404
+ ```
1405
+
1406
+ ### Key Rules
1407
+
1408
+ 1. **ONE watcher per trigger field** - consolidate all logic for `insuranceType` into single `watchField`
1409
+ 2. **ALWAYS use `{ immediate: false }`** - prevents execution during initialization
1410
+ 3. **Guard disable()** - only call if `!field.disabled.value`
1411
+ 4. **Guard enable()** - only call if `field.disabled.value`
1412
+ 5. **Guard setValue()** - only call if value actually differs
1413
+ 6. **Arrays special case** - compare by `.length`, not by reference (`[] !== []` is always true)
1414
+
1415
+ ### Other Watchers
1416
+
1417
+ For watchers on different fields (e.g., `path.health.isSmoker`), apply same guards:
1418
+
1419
+ ```typescript
1420
+ watchField(path.health.isSmoker, (_value, ctx) => {
1421
+ const isSmoker = ctx.form.health.isSmoker.value.value;
1422
+ const smokingYearsField = ctx.form.health.smokingYears;
1423
+
1424
+ if (smokingYearsField) {
1425
+ if (isSmoker) {
1426
+ if (smokingYearsField.disabled.value) {
1427
+ smokingYearsField.enable();
1428
+ }
1429
+ } else {
1430
+ if (!smokingYearsField.disabled.value) {
1431
+ smokingYearsField.disable();
1432
+ }
1433
+ if (smokingYearsField.getValue() !== null) {
1434
+ smokingYearsField.setValue(null);
1435
+ }
1436
+ }
1437
+ }
1438
+ }, { immediate: false }); // REQUIRED!
1439
+ ```