@lucas-barake/effect-form-react 0.12.0 → 0.13.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.
package/src/FormReact.tsx CHANGED
@@ -1,10 +1,18 @@
1
- import { RegistryContext, useAtom, useAtomSet, useAtomSubscribe, useAtomValue } from "@effect-atom/atom-react"
2
- import type * as Atom from "@effect-atom/atom/Atom"
1
+ import {
2
+ RegistryContext,
3
+ useAtom,
4
+ useAtomMount,
5
+ useAtomSet,
6
+ useAtomSubscribe,
7
+ useAtomValue,
8
+ } from "@effect-atom/atom-react"
9
+ import * as Atom from "@effect-atom/atom/Atom"
3
10
  import { Field, FormAtoms, Mode, Validation } from "@lucas-barake/effect-form"
4
11
  import type * as FormBuilder from "@lucas-barake/effect-form/FormBuilder"
5
- import { getNestedValue, isPathOrParentDirty, isPathUnderRoot } from "@lucas-barake/effect-form/Path"
12
+ import { getNestedValue, isPathOrParentDirty } from "@lucas-barake/effect-form/Path"
6
13
  import * as Cause from "effect/Cause"
7
14
  import type * as Effect from "effect/Effect"
15
+ import * as Layer from "effect/Layer"
8
16
  import * as Option from "effect/Option"
9
17
  import * as ParseResult from "effect/ParseResult"
10
18
  import * as Predicate from "effect/Predicate"
@@ -130,7 +138,6 @@ export type BuiltForm<
130
138
  SubmitArgs = void,
131
139
  CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
132
140
  > = {
133
- // Atoms for fine-grained subscriptions (use with useAtomValue)
134
141
  readonly values: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
135
142
  readonly isDirty: Atom.Atom<boolean>
136
143
  readonly hasChangedSinceSubmit: Atom.Atom<boolean>
@@ -151,6 +158,9 @@ export type BuiltForm<
151
158
  readonly setValues: Atom.Writable<void, Field.EncodedFromFields<TFields>>
152
159
  readonly setValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Writable<void, S | ((prev: S) => S)>
153
160
  readonly getFieldAtom: <S>(field: FormBuilder.FieldRef<S>) => Atom.Atom<Option.Option<S>>
161
+
162
+ readonly mount: Atom.Atom<void>
163
+ readonly KeepAlive: React.FC
154
164
  } & FieldComponents<TFields, CM>
155
165
 
156
166
  type FieldComponents<TFields extends Field.FieldsRecord, CM extends FieldComponentMap<TFields>> = {
@@ -193,7 +203,7 @@ const AutoSubmitContext = createContext<(() => void) | null>(null)
193
203
  const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string, unknown>>(
194
204
  fieldKey: string,
195
205
  fieldDef: Field.FieldDef<string, S>,
196
- crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
206
+ errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
197
207
  submitCountAtom: Atom.Atom<number>,
198
208
  dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
199
209
  parsedMode: Mode.ParsedMode,
@@ -209,15 +219,14 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
209
219
  const autoSubmitOnBlur = useContext(AutoSubmitContext)
210
220
  const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
211
221
 
212
- const { crossFieldErrorAtom, touchedAtom, valueAtom } = React.useMemo(
222
+ const { errorAtom, touchedAtom, valueAtom } = React.useMemo(
213
223
  () => getOrCreateFieldAtoms(fieldPath),
214
224
  [fieldPath],
215
225
  )
216
226
 
217
227
  const [value, setValue] = useAtom(valueAtom) as [Schema.Schema.Encoded<S>, (v: unknown) => void]
218
228
  const [isTouched, setTouched] = useAtom(touchedAtom)
219
- const crossFieldError = useAtomValue(crossFieldErrorAtom)
220
- const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
229
+ const storedError = useAtomValue(errorAtom)
221
230
  const submitCount = useAtomValue(submitCountAtom)
222
231
 
223
232
  const validationAtom = React.useMemo(
@@ -241,13 +250,14 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
241
250
 
242
251
  const shouldValidate = parsedMode.validation === "onChange"
243
252
  || (parsedMode.validation === "onBlur" && isTouched)
253
+ || (parsedMode.validation === "onSubmit" && submitCount > 0)
244
254
 
245
255
  if (shouldValidate) {
246
256
  validate(value)
247
257
  }
248
- }, [value, isTouched, validate])
258
+ }, [value, isTouched, submitCount, validate])
249
259
 
250
- const perFieldError: Option.Option<string> = React.useMemo(() => {
260
+ const livePerFieldError: Option.Option<string> = React.useMemo(() => {
251
261
  if (validationResult._tag === "Failure") {
252
262
  const parseError = Cause.failureOption(validationResult.cause)
253
263
  if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
@@ -257,25 +267,33 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
257
267
  return Option.none()
258
268
  }, [validationResult])
259
269
 
260
- const validationError = Option.isSome(perFieldError) ? perFieldError : crossFieldError
270
+ const isValidating = validationResult.waiting
271
+
272
+ const validationError: Option.Option<string> = React.useMemo(() => {
273
+ if (Option.isSome(livePerFieldError)) {
274
+ return livePerFieldError
275
+ }
276
+
277
+ if (Option.isSome(storedError)) {
278
+ // Hide field-sourced errors when validation passes or is pending (async gap).
279
+ // Refinement errors persist until re-submit - they can't be cleared by typing.
280
+ const shouldHideStoredError = storedError.value.source === "field" &&
281
+ (validationResult._tag === "Success" || isValidating)
282
+
283
+ if (shouldHideStoredError) {
284
+ return Option.none()
285
+ }
286
+ return Option.some(storedError.value.message)
287
+ }
288
+
289
+ return Option.none()
290
+ }, [livePerFieldError, storedError, validationResult, isValidating])
261
291
 
262
292
  const onChange = React.useCallback(
263
293
  (newValue: Schema.Schema.Encoded<S>) => {
264
294
  setValue(newValue)
265
- setCrossFieldErrors((prev) => {
266
- const next = new Map<string, string>()
267
- for (const [errorPath, message] of prev) {
268
- if (!isPathUnderRoot(errorPath, fieldPath)) {
269
- next.set(errorPath, message)
270
- }
271
- }
272
- return next.size !== prev.size ? next : prev
273
- })
274
- if (parsedMode.validation === "onChange") {
275
- validate(newValue)
276
- }
277
295
  },
278
- [fieldPath, setValue, setCrossFieldErrors, validate],
296
+ [setValue],
279
297
  )
280
298
 
281
299
  const onBlur = React.useCallback(() => {
@@ -291,8 +309,11 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
291
309
  () => isPathOrParentDirty(dirtyFields, fieldPath),
292
310
  [dirtyFields, fieldPath],
293
311
  )
294
- const isValidating = validationResult.waiting
295
- const shouldShowError = isTouched || submitCount > 0
312
+ const shouldShowError = parsedMode.validation === "onChange"
313
+ ? (isDirty || submitCount > 0)
314
+ : parsedMode.validation === "onBlur"
315
+ ? (isTouched || submitCount > 0)
316
+ : submitCount > 0
296
317
 
297
318
  const fieldState: FieldState<S> = React.useMemo(() => ({
298
319
  value,
@@ -314,7 +335,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
314
335
  fieldKey: string,
315
336
  def: Field.ArrayFieldDef<string, S>,
316
337
  stateAtom: Atom.Writable<Option.Option<FormBuilder.FormState<any>>, Option.Option<FormBuilder.FormState<any>>>,
317
- crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
338
+ errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
318
339
  submitCountAtom: Atom.Atom<number>,
319
340
  dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
320
341
  parsedMode: Mode.ParsedMode,
@@ -420,7 +441,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
420
441
  itemFieldComponents[itemKey] = makeFieldComponent(
421
442
  itemKey,
422
443
  itemDef,
423
- crossFieldErrorsAtom,
444
+ errorsAtom,
424
445
  submitCountAtom,
425
446
  dirtyFieldsAtom,
426
447
  parsedMode,
@@ -436,7 +457,6 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
436
457
  ...itemFieldComponents,
437
458
  }
438
459
 
439
- // Proxy enables <Form.items.Item> and <Form.items.name> syntax
440
460
  return new Proxy(ArrayWrapper, {
441
461
  get(target, prop) {
442
462
  if (prop in properties) {
@@ -456,7 +476,7 @@ const makeFieldComponents = <
456
476
  Option.Option<FormBuilder.FormState<TFields>>,
457
477
  Option.Option<FormBuilder.FormState<TFields>>
458
478
  >,
459
- crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
479
+ errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
460
480
  submitCountAtom: Atom.Atom<number>,
461
481
  dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
462
482
  parsedMode: Mode.ParsedMode,
@@ -477,7 +497,7 @@ const makeFieldComponents = <
477
497
  key,
478
498
  def as Field.ArrayFieldDef<string, Schema.Schema.Any>,
479
499
  stateAtom,
480
- crossFieldErrorsAtom,
500
+ errorsAtom,
481
501
  submitCountAtom,
482
502
  dirtyFieldsAtom,
483
503
  parsedMode,
@@ -494,7 +514,7 @@ const makeFieldComponents = <
494
514
  components[key] = makeFieldComponent(
495
515
  key,
496
516
  def,
497
- crossFieldErrorsAtom,
517
+ errorsAtom,
498
518
  submitCountAtom,
499
519
  dirtyFieldsAtom,
500
520
  parsedMode,
@@ -509,34 +529,30 @@ const makeFieldComponents = <
509
529
  }
510
530
 
511
531
  /**
512
- * Builds a React form from a FormBuilder.
532
+ * Creates a React form from a FormBuilder.
513
533
  *
514
534
  * @example
515
535
  * ```tsx
516
536
  * import { FormBuilder } from "@lucas-barake/effect-form"
517
537
  * import { FormReact } from "@lucas-barake/effect-form-react"
518
538
  * import { useAtomValue, useAtomSet } from "@effect-atom/atom-react"
519
- * import * as Atom from "@effect-atom/atom/Atom"
520
539
  * import * as Schema from "effect/Schema"
521
- * import * as Layer from "effect/Layer"
522
- *
523
- * const runtime = Atom.runtime(Layer.empty)
524
540
  *
525
- * const loginForm = FormBuilder.empty
541
+ * const loginFormBuilder = FormBuilder.empty
526
542
  * .addField("email", Schema.String)
527
543
  * .addField("password", Schema.String)
528
544
  *
529
- * const form = FormReact.build(loginForm, {
530
- * runtime,
545
+ * // Runtime is optional for forms without service requirements
546
+ * const loginForm = FormReact.make(loginFormBuilder, {
531
547
  * fields: { email: TextInput, password: PasswordInput },
532
- * onSubmit: (values) => Effect.log(`Login: ${values.email}`),
548
+ * onSubmit: (_, { decoded }) => Effect.log(`Login: ${decoded.email}`),
533
549
  * })
534
550
  *
535
551
  * // Subscribe to atoms anywhere in the tree
536
552
  * function SubmitButton() {
537
- * const isDirty = useAtomValue(form.isDirty)
538
- * const submit = useAtomValue(form.submit)
539
- * const callSubmit = useAtomSet(form.submit)
553
+ * const isDirty = useAtomValue(loginForm.isDirty)
554
+ * const submit = useAtomValue(loginForm.submit)
555
+ * const callSubmit = useAtomSet(loginForm.submit)
540
556
  * return (
541
557
  * <button onClick={() => callSubmit()} disabled={!isDirty || submit.waiting}>
542
558
  * {submit.waiting ? "Validating..." : "Login"}
@@ -544,48 +560,74 @@ const makeFieldComponents = <
544
560
  * )
545
561
  * }
546
562
  *
547
- * function LoginDialog({ onClose }) {
563
+ * function LoginPage() {
548
564
  * return (
549
- * <form.Initialize defaultValues={{ email: "", password: "" }}>
550
- * <form.email />
551
- * <form.password />
565
+ * <loginForm.Initialize defaultValues={{ email: "", password: "" }}>
566
+ * <loginForm.email />
567
+ * <loginForm.password />
552
568
  * <SubmitButton />
553
- * </form.Initialize>
569
+ * </loginForm.Initialize>
554
570
  * )
555
571
  * }
556
572
  * ```
557
573
  *
558
574
  * @category Constructors
559
575
  */
560
- export const build = <
561
- TFields extends Field.FieldsRecord,
562
- R,
563
- A,
564
- E,
565
- SubmitArgs = void,
566
- ER = never,
567
- CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
568
- >(
569
- self: FormBuilder.FormBuilder<TFields, R>,
570
- options: {
571
- readonly runtime: Atom.AtomRuntime<R, ER>
572
- readonly fields: CM
573
- readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit
574
- readonly onSubmit: (
575
- args: SubmitArgs,
576
- ctx: {
577
- readonly decoded: Field.DecodedFromFields<TFields>
578
- readonly encoded: Field.EncodedFromFields<TFields>
579
- readonly get: Atom.FnContext
580
- },
581
- ) => A | Effect.Effect<A, E, R>
582
- },
583
- ): BuiltForm<TFields, R, A, E, SubmitArgs, CM> => {
584
- const { fields: components, mode, onSubmit, runtime } = options
576
+ export const make: {
577
+ <
578
+ TFields extends Field.FieldsRecord,
579
+ A,
580
+ E,
581
+ SubmitArgs = void,
582
+ CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
583
+ >(
584
+ self: FormBuilder.FormBuilder<TFields, never>,
585
+ options: {
586
+ readonly runtime?: Atom.AtomRuntime<never, never>
587
+ readonly fields: CM
588
+ readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit
589
+ readonly onSubmit: (
590
+ args: SubmitArgs,
591
+ ctx: {
592
+ readonly decoded: Field.DecodedFromFields<TFields>
593
+ readonly encoded: Field.EncodedFromFields<TFields>
594
+ readonly get: Atom.FnContext
595
+ },
596
+ ) => A | Effect.Effect<A, E, never>
597
+ },
598
+ ): BuiltForm<TFields, never, A, E, SubmitArgs, CM>
599
+
600
+ <
601
+ TFields extends Field.FieldsRecord,
602
+ R,
603
+ A,
604
+ E,
605
+ SubmitArgs = void,
606
+ ER = never,
607
+ CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
608
+ >(
609
+ self: FormBuilder.FormBuilder<TFields, R>,
610
+ options: {
611
+ readonly runtime: Atom.AtomRuntime<R, ER>
612
+ readonly fields: CM
613
+ readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit
614
+ readonly onSubmit: (
615
+ args: SubmitArgs,
616
+ ctx: {
617
+ readonly decoded: Field.DecodedFromFields<TFields>
618
+ readonly encoded: Field.EncodedFromFields<TFields>
619
+ readonly get: Atom.FnContext
620
+ },
621
+ ) => A | Effect.Effect<A, E, R>
622
+ },
623
+ ): BuiltForm<TFields, R, A, E, SubmitArgs, CM>
624
+ } = (self: any, options: any): any => {
625
+ const { fields: components, mode, onSubmit, runtime: providedRuntime } = options
626
+ const runtime = providedRuntime ?? Atom.runtime(Layer.empty)
585
627
  const parsedMode = Mode.parse(mode)
586
628
  const { fields } = self
587
629
 
588
- const formAtoms: FormAtoms.FormAtoms<TFields, R, A, E, SubmitArgs> = FormAtoms.make({
630
+ const formAtoms = FormAtoms.make({
589
631
  formBuilder: self,
590
632
  runtime,
591
633
  onSubmit,
@@ -593,18 +635,21 @@ export const build = <
593
635
 
594
636
  const {
595
637
  combinedSchema,
596
- crossFieldErrorsAtom,
597
638
  dirtyFieldsAtom,
639
+ errorsAtom,
598
640
  fieldRefs,
599
641
  getFieldAtom,
600
642
  getOrCreateFieldAtoms,
601
643
  getOrCreateValidationAtom,
602
644
  hasChangedSinceSubmitAtom,
603
645
  isDirtyAtom,
646
+ keepAliveActiveAtom,
604
647
  lastSubmittedValuesAtom,
648
+ mountAtom,
605
649
  operations,
606
650
  resetAtom,
607
651
  revertToLastSubmitAtom,
652
+ rootErrorAtom,
608
653
  setValue,
609
654
  setValuesAtom,
610
655
  stateAtom,
@@ -614,7 +659,7 @@ export const build = <
614
659
  } = formAtoms
615
660
 
616
661
  const InitializeComponent: React.FC<{
617
- readonly defaultValues: Field.EncodedFromFields<TFields>
662
+ readonly defaultValues: any
618
663
  readonly children: React.ReactNode
619
664
  }> = ({ children, defaultValues }) => {
620
665
  const registry = React.useContext(RegistryContext)
@@ -624,56 +669,110 @@ export const build = <
624
669
  const isInitializedRef = React.useRef(false)
625
670
 
626
671
  React.useEffect(() => {
627
- setFormState(Option.some(operations.createInitialState(defaultValues)))
672
+ const isKeptAlive = registry.get(keepAliveActiveAtom)
673
+ const currentState = registry.get(stateAtom)
674
+
675
+ if (!isKeptAlive) {
676
+ setFormState(Option.some(operations.createInitialState(defaultValues)))
677
+ } else if (Option.isNone(currentState)) {
678
+ setFormState(Option.some(operations.createInitialState(defaultValues)))
679
+ }
680
+
628
681
  isInitializedRef.current = true
629
682
  // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
630
- }, [])
683
+ }, [registry])
631
684
 
632
685
  const debouncedAutoSubmit = useDebounced(() => {
633
686
  const stateOption = registry.get(stateAtom)
634
687
  if (Option.isNone(stateOption)) return
635
- callSubmit(undefined as SubmitArgs)
688
+ callSubmit(undefined)
636
689
  }, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
637
690
 
691
+ // ─────────────────────────────────────────────────────────────────────────────
692
+ // Auto-Submit Coordination
693
+ // ─────────────────────────────────────────────────────────────────────────────
694
+ // Two-subscription model to avoid infinite loop:
695
+ // - Stream 1 reacts to value changes (reference equality), triggers or queues submit
696
+ // - Stream 2 reacts to submit completion, flushes queued changes
697
+ //
698
+ // Single subscription to stateAtom cannot distinguish value changes from submit
699
+ // metadata updates (submitCount, lastSubmittedValues).
700
+ // ─────────────────────────────────────────────────────────────────────────────
701
+
702
+ const lastValuesRef = React.useRef<unknown>(null)
703
+ const pendingChangesRef = React.useRef(false)
704
+ const wasSubmittingRef = React.useRef(false)
705
+
638
706
  useAtomSubscribe(
639
707
  stateAtom,
640
708
  React.useCallback(() => {
641
709
  if (!isInitializedRef.current) return
642
- if (parsedMode.autoSubmit && parsedMode.validation === "onChange") {
710
+
711
+ const state = registry.get(stateAtom)
712
+ if (Option.isNone(state)) return
713
+ const currentValues = state.value.values
714
+
715
+ // Reference equality filters out submit metadata changes.
716
+ // Works because setFieldValue creates new values object (immutable update).
717
+ if (currentValues === lastValuesRef.current) return
718
+ lastValuesRef.current = currentValues
719
+
720
+ if (!parsedMode.autoSubmit || parsedMode.validation !== "onChange") return
721
+
722
+ const submitResult = registry.get(submitAtom)
723
+ if (submitResult.waiting) {
724
+ pendingChangesRef.current = true
725
+ } else {
643
726
  debouncedAutoSubmit()
644
727
  }
645
- }, [debouncedAutoSubmit]),
728
+ }, [debouncedAutoSubmit, registry]),
729
+ { immediate: false },
730
+ )
731
+
732
+ useAtomSubscribe(
733
+ submitAtom,
734
+ React.useCallback(
735
+ (result) => {
736
+ if (!parsedMode.autoSubmit || parsedMode.validation !== "onChange") return
737
+
738
+ const isSubmitting = result.waiting
739
+ const wasSubmitting = wasSubmittingRef.current
740
+ wasSubmittingRef.current = isSubmitting
741
+
742
+ // Flush queued changes when submit completes
743
+ if (wasSubmitting && !isSubmitting) {
744
+ if (pendingChangesRef.current) {
745
+ pendingChangesRef.current = false
746
+ debouncedAutoSubmit()
747
+ }
748
+ }
749
+ },
750
+ [debouncedAutoSubmit],
751
+ ),
646
752
  { immediate: false },
647
753
  )
648
754
 
649
755
  const onBlurAutoSubmit = React.useCallback(() => {
650
- if (parsedMode.autoSubmit && parsedMode.validation === "onBlur") {
651
- const stateOption = registry.get(stateAtom)
652
- if (Option.isNone(stateOption)) return
653
- callSubmit(undefined as SubmitArgs)
654
- }
756
+ if (!parsedMode.autoSubmit || parsedMode.validation !== "onBlur") return
757
+
758
+ const stateOption = registry.get(stateAtom)
759
+ if (Option.isNone(stateOption)) return
760
+
761
+ const { lastSubmittedValues, values } = stateOption.value
762
+ if (Option.isSome(lastSubmittedValues) && values === lastSubmittedValues.value.encoded) return
763
+
764
+ callSubmit(undefined)
655
765
  }, [registry, callSubmit])
656
766
 
657
767
  if (Option.isNone(state)) return null
658
768
 
659
- return (
660
- <AutoSubmitContext.Provider value={onBlurAutoSubmit}>
661
- <form
662
- onSubmit={(e) => {
663
- e.preventDefault()
664
- e.stopPropagation()
665
- }}
666
- >
667
- {children}
668
- </form>
669
- </AutoSubmitContext.Provider>
670
- )
769
+ return <AutoSubmitContext.Provider value={onBlurAutoSubmit}>{children}</AutoSubmitContext.Provider>
671
770
  }
672
771
 
673
772
  const fieldComponents = makeFieldComponents(
674
773
  fields,
675
774
  stateAtom,
676
- crossFieldErrorsAtom,
775
+ errorsAtom,
677
776
  submitCountAtom,
678
777
  dirtyFieldsAtom,
679
778
  parsedMode,
@@ -683,12 +782,25 @@ export const build = <
683
782
  components,
684
783
  )
685
784
 
785
+ const KeepAlive: React.FC = () => {
786
+ const setKeepAliveActive = useAtomSet(keepAliveActiveAtom)
787
+
788
+ React.useLayoutEffect(() => {
789
+ setKeepAliveActive(true)
790
+ return () => setKeepAliveActive(false)
791
+ }, [setKeepAliveActive])
792
+
793
+ useAtomMount(mountAtom)
794
+ return null
795
+ }
796
+
686
797
  return {
687
798
  values: valuesAtom,
688
799
  isDirty: isDirtyAtom,
689
800
  hasChangedSinceSubmit: hasChangedSinceSubmitAtom,
690
801
  lastSubmittedValues: lastSubmittedValuesAtom,
691
802
  submitCount: submitCountAtom,
803
+ rootError: rootErrorAtom,
692
804
  schema: combinedSchema,
693
805
  fields: fieldRefs,
694
806
  Initialize: InitializeComponent,
@@ -698,8 +810,10 @@ export const build = <
698
810
  setValues: setValuesAtom,
699
811
  setValue,
700
812
  getFieldAtom,
813
+ mount: mountAtom,
814
+ KeepAlive,
701
815
  ...fieldComponents,
702
- } as BuiltForm<TFields, R, A, E, SubmitArgs, CM>
816
+ }
703
817
  }
704
818
 
705
819
  /**
@@ -767,10 +881,10 @@ export const forField = <K extends string, S extends Schema.Schema.Any>(
767
881
  * ))
768
882
  *
769
883
  * // Use in form builder
770
- * const form = FormBuilder.empty.addField(NameInput.field)
884
+ * const formBuilder = FormBuilder.empty.addField(NameInput.field)
771
885
  *
772
- * // Use in build()
773
- * const Form = FormReact.build(form, {
886
+ * // Use in make()
887
+ * const form = FormReact.make(formBuilder, {
774
888
  * runtime,
775
889
  * fields: { name: NameInput },
776
890
  * onSubmit: (_, { decoded }) => Effect.log(decoded.name),
@@ -783,20 +897,16 @@ export const makeField = <K extends string, S extends Schema.Schema.Any>(options
783
897
  readonly key: K
784
898
  readonly schema: S
785
899
  }): <P extends Record<string, unknown> = Record<string, never>>(
786
- component: React.FC<FieldComponentProps<S, P>>
900
+ component: React.FC<FieldComponentProps<S, P>>,
787
901
  ) => FieldBundle<K, S, P> => {
788
902
  const field = Field.makeField(options.key, options.schema)
789
903
  return (component) => {
790
- // DX: Auto-generate a readable component name for DevTools
791
- // e.g., key "email" -> "<EmailField />"
792
904
  if (!component.displayName) {
793
905
  const displayName = `${options.key.charAt(0).toUpperCase()}${options.key.slice(1)}Field`
794
- // Cast to 'any' because strict TS marks function names as readonly,
795
- // but React relies on mutation here.
796
906
  try {
797
907
  ;(component as any).displayName = displayName
798
908
  } catch {
799
- // Ignore errors in environments where function props are frozen
909
+ // Ignore - some environments freeze function properties
800
910
  }
801
911
  }
802
912
  return {