@lucas-barake/effect-form-react 0.11.0 → 0.13.0

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,12 +1,21 @@
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"
18
+ import * as Predicate from "effect/Predicate"
10
19
  import type * as Schema from "effect/Schema"
11
20
  import * as AST from "effect/SchemaAST"
12
21
  import * as React from "react"
@@ -42,12 +51,32 @@ export interface FieldComponentProps<
42
51
  readonly props: P
43
52
  }
44
53
 
54
+ /**
55
+ * A bundled field definition + component for reusable form fields.
56
+ * Created with `FormReact.makeField`.
57
+ *
58
+ * @category Models
59
+ */
60
+ export interface FieldBundle<
61
+ K extends string,
62
+ S extends Schema.Schema.Any,
63
+ P extends Record<string, unknown> = Record<string, never>,
64
+ > {
65
+ readonly _tag: "FieldBundle"
66
+ readonly field: Field.FieldDef<K, S>
67
+ readonly component: React.FC<FieldComponentProps<S, P>>
68
+ }
69
+
70
+ const isFieldBundle = (x: unknown): x is FieldBundle<string, Schema.Schema.Any, Record<string, unknown>> =>
71
+ Predicate.isTagged(x, "FieldBundle")
72
+
45
73
  /**
46
74
  * Extracts the extra props type from a field component.
47
75
  *
48
76
  * @category Type-level utilities
49
77
  */
50
78
  export type ExtractExtraProps<C> = C extends React.FC<FieldComponentProps<any, infer P>> ? P
79
+ : C extends FieldBundle<any, any, infer P> ? P
51
80
  : Record<string, never>
52
81
 
53
82
  /**
@@ -69,7 +98,8 @@ export type ArrayItemComponentMap<S extends Schema.Schema.Any> = S extends Schem
69
98
  * @category Models
70
99
  */
71
100
  export type FieldComponentMap<TFields extends Field.FieldsRecord> = {
72
- readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S> ? React.FC<FieldComponentProps<S, any>>
101
+ readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S>
102
+ ? React.FC<FieldComponentProps<S, any>> | FieldBundle<any, S, any>
73
103
  : TFields[K] extends Field.ArrayFieldDef<any, infer S> ? ArrayItemComponentMap<S>
74
104
  : never
75
105
  }
@@ -108,7 +138,6 @@ export type BuiltForm<
108
138
  SubmitArgs = void,
109
139
  CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
110
140
  > = {
111
- // Atoms for fine-grained subscriptions (use with useAtomValue)
112
141
  readonly values: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
113
142
  readonly isDirty: Atom.Atom<boolean>
114
143
  readonly hasChangedSinceSubmit: Atom.Atom<boolean>
@@ -128,7 +157,10 @@ export type BuiltForm<
128
157
  readonly revertToLastSubmit: Atom.Writable<void, void>
129
158
  readonly setValues: Atom.Writable<void, Field.EncodedFromFields<TFields>>
130
159
  readonly setValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Writable<void, S | ((prev: S) => S)>
131
- readonly getFieldAtom: <S>(field: FormBuilder.FieldRef<S>) => Atom.Atom<S>
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
132
164
  } & FieldComponents<TFields, CM>
133
165
 
134
166
  type FieldComponents<TFields extends Field.FieldsRecord, CM extends FieldComponentMap<TFields>> = {
@@ -171,7 +203,7 @@ const AutoSubmitContext = createContext<(() => void) | null>(null)
171
203
  const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string, unknown>>(
172
204
  fieldKey: string,
173
205
  fieldDef: Field.FieldDef<string, S>,
174
- crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
206
+ errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
175
207
  submitCountAtom: Atom.Atom<number>,
176
208
  dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
177
209
  parsedMode: Mode.ParsedMode,
@@ -187,15 +219,14 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
187
219
  const autoSubmitOnBlur = useContext(AutoSubmitContext)
188
220
  const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
189
221
 
190
- const { crossFieldErrorAtom, touchedAtom, valueAtom } = React.useMemo(
222
+ const { errorAtom, touchedAtom, valueAtom } = React.useMemo(
191
223
  () => getOrCreateFieldAtoms(fieldPath),
192
224
  [fieldPath],
193
225
  )
194
226
 
195
227
  const [value, setValue] = useAtom(valueAtom) as [Schema.Schema.Encoded<S>, (v: unknown) => void]
196
228
  const [isTouched, setTouched] = useAtom(touchedAtom)
197
- const crossFieldError = useAtomValue(crossFieldErrorAtom)
198
- const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
229
+ const storedError = useAtomValue(errorAtom)
199
230
  const submitCount = useAtomValue(submitCountAtom)
200
231
 
201
232
  const validationAtom = React.useMemo(
@@ -219,13 +250,14 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
219
250
 
220
251
  const shouldValidate = parsedMode.validation === "onChange"
221
252
  || (parsedMode.validation === "onBlur" && isTouched)
253
+ || (parsedMode.validation === "onSubmit" && submitCount > 0)
222
254
 
223
255
  if (shouldValidate) {
224
256
  validate(value)
225
257
  }
226
- }, [value, isTouched, validate])
258
+ }, [value, isTouched, submitCount, validate])
227
259
 
228
- const perFieldError: Option.Option<string> = React.useMemo(() => {
260
+ const livePerFieldError: Option.Option<string> = React.useMemo(() => {
229
261
  if (validationResult._tag === "Failure") {
230
262
  const parseError = Cause.failureOption(validationResult.cause)
231
263
  if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
@@ -235,25 +267,33 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
235
267
  return Option.none()
236
268
  }, [validationResult])
237
269
 
238
- 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])
239
291
 
240
292
  const onChange = React.useCallback(
241
293
  (newValue: Schema.Schema.Encoded<S>) => {
242
294
  setValue(newValue)
243
- setCrossFieldErrors((prev) => {
244
- const next = new Map<string, string>()
245
- for (const [errorPath, message] of prev) {
246
- if (!isPathUnderRoot(errorPath, fieldPath)) {
247
- next.set(errorPath, message)
248
- }
249
- }
250
- return next.size !== prev.size ? next : prev
251
- })
252
- if (parsedMode.validation === "onChange") {
253
- validate(newValue)
254
- }
255
295
  },
256
- [fieldPath, setValue, setCrossFieldErrors, validate],
296
+ [setValue],
257
297
  )
258
298
 
259
299
  const onBlur = React.useCallback(() => {
@@ -269,8 +309,11 @@ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string
269
309
  () => isPathOrParentDirty(dirtyFields, fieldPath),
270
310
  [dirtyFields, fieldPath],
271
311
  )
272
- const isValidating = validationResult.waiting
273
- 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
274
317
 
275
318
  const fieldState: FieldState<S> = React.useMemo(() => ({
276
319
  value,
@@ -292,7 +335,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
292
335
  fieldKey: string,
293
336
  def: Field.ArrayFieldDef<string, S>,
294
337
  stateAtom: Atom.Writable<Option.Option<FormBuilder.FormState<any>>, Option.Option<FormBuilder.FormState<any>>>,
295
- crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
338
+ errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
296
339
  submitCountAtom: Atom.Atom<number>,
297
340
  dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
298
341
  parsedMode: Mode.ParsedMode,
@@ -398,7 +441,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
398
441
  itemFieldComponents[itemKey] = makeFieldComponent(
399
442
  itemKey,
400
443
  itemDef,
401
- crossFieldErrorsAtom,
444
+ errorsAtom,
402
445
  submitCountAtom,
403
446
  dirtyFieldsAtom,
404
447
  parsedMode,
@@ -414,7 +457,6 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
414
457
  ...itemFieldComponents,
415
458
  }
416
459
 
417
- // Proxy enables <Form.items.Item> and <Form.items.name> syntax
418
460
  return new Proxy(ArrayWrapper, {
419
461
  get(target, prop) {
420
462
  if (prop in properties) {
@@ -434,7 +476,7 @@ const makeFieldComponents = <
434
476
  Option.Option<FormBuilder.FormState<TFields>>,
435
477
  Option.Option<FormBuilder.FormState<TFields>>
436
478
  >,
437
- crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
479
+ errorsAtom: Atom.Writable<Map<string, Validation.ErrorEntry>, Map<string, Validation.ErrorEntry>>,
438
480
  submitCountAtom: Atom.Atom<number>,
439
481
  dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
440
482
  parsedMode: Mode.ParsedMode,
@@ -455,7 +497,7 @@ const makeFieldComponents = <
455
497
  key,
456
498
  def as Field.ArrayFieldDef<string, Schema.Schema.Any>,
457
499
  stateAtom,
458
- crossFieldErrorsAtom,
500
+ errorsAtom,
459
501
  submitCountAtom,
460
502
  dirtyFieldsAtom,
461
503
  parsedMode,
@@ -465,11 +507,14 @@ const makeFieldComponents = <
465
507
  arrayComponentMap,
466
508
  )
467
509
  } else if (Field.isFieldDef(def)) {
468
- const fieldComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any, any>>>)[key]
510
+ const componentOrBundle = (componentMap as Record<string, unknown>)[key]
511
+ const fieldComponent = isFieldBundle(componentOrBundle)
512
+ ? componentOrBundle.component
513
+ : componentOrBundle as React.FC<FieldComponentProps<any, any>>
469
514
  components[key] = makeFieldComponent(
470
515
  key,
471
516
  def,
472
- crossFieldErrorsAtom,
517
+ errorsAtom,
473
518
  submitCountAtom,
474
519
  dirtyFieldsAtom,
475
520
  parsedMode,
@@ -484,34 +529,30 @@ const makeFieldComponents = <
484
529
  }
485
530
 
486
531
  /**
487
- * Builds a React form from a FormBuilder.
532
+ * Creates a React form from a FormBuilder.
488
533
  *
489
534
  * @example
490
535
  * ```tsx
491
536
  * import { FormBuilder } from "@lucas-barake/effect-form"
492
537
  * import { FormReact } from "@lucas-barake/effect-form-react"
493
538
  * import { useAtomValue, useAtomSet } from "@effect-atom/atom-react"
494
- * import * as Atom from "@effect-atom/atom/Atom"
495
539
  * import * as Schema from "effect/Schema"
496
- * import * as Layer from "effect/Layer"
497
- *
498
- * const runtime = Atom.runtime(Layer.empty)
499
540
  *
500
- * const loginForm = FormBuilder.empty
541
+ * const loginFormBuilder = FormBuilder.empty
501
542
  * .addField("email", Schema.String)
502
543
  * .addField("password", Schema.String)
503
544
  *
504
- * const form = FormReact.build(loginForm, {
505
- * runtime,
545
+ * // Runtime is optional for forms without service requirements
546
+ * const loginForm = FormReact.make(loginFormBuilder, {
506
547
  * fields: { email: TextInput, password: PasswordInput },
507
- * onSubmit: (values) => Effect.log(`Login: ${values.email}`),
548
+ * onSubmit: (_, { decoded }) => Effect.log(`Login: ${decoded.email}`),
508
549
  * })
509
550
  *
510
551
  * // Subscribe to atoms anywhere in the tree
511
552
  * function SubmitButton() {
512
- * const isDirty = useAtomValue(form.isDirty)
513
- * const submit = useAtomValue(form.submit)
514
- * const callSubmit = useAtomSet(form.submit)
553
+ * const isDirty = useAtomValue(loginForm.isDirty)
554
+ * const submit = useAtomValue(loginForm.submit)
555
+ * const callSubmit = useAtomSet(loginForm.submit)
515
556
  * return (
516
557
  * <button onClick={() => callSubmit()} disabled={!isDirty || submit.waiting}>
517
558
  * {submit.waiting ? "Validating..." : "Login"}
@@ -519,48 +560,74 @@ const makeFieldComponents = <
519
560
  * )
520
561
  * }
521
562
  *
522
- * function LoginDialog({ onClose }) {
563
+ * function LoginPage() {
523
564
  * return (
524
- * <form.Initialize defaultValues={{ email: "", password: "" }}>
525
- * <form.email />
526
- * <form.password />
565
+ * <loginForm.Initialize defaultValues={{ email: "", password: "" }}>
566
+ * <loginForm.email />
567
+ * <loginForm.password />
527
568
  * <SubmitButton />
528
- * </form.Initialize>
569
+ * </loginForm.Initialize>
529
570
  * )
530
571
  * }
531
572
  * ```
532
573
  *
533
574
  * @category Constructors
534
575
  */
535
- export const build = <
536
- TFields extends Field.FieldsRecord,
537
- R,
538
- A,
539
- E,
540
- SubmitArgs = void,
541
- ER = never,
542
- CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
543
- >(
544
- self: FormBuilder.FormBuilder<TFields, R>,
545
- options: {
546
- readonly runtime: Atom.AtomRuntime<R, ER>
547
- readonly fields: CM
548
- readonly mode?: SubmitArgs extends void ? Mode.FormMode : Mode.FormModeWithoutAutoSubmit
549
- readonly onSubmit: (
550
- args: SubmitArgs,
551
- ctx: {
552
- readonly decoded: Field.DecodedFromFields<TFields>
553
- readonly encoded: Field.EncodedFromFields<TFields>
554
- readonly get: Atom.FnContext
555
- },
556
- ) => A | Effect.Effect<A, E, R>
557
- },
558
- ): BuiltForm<TFields, R, A, E, SubmitArgs, CM> => {
559
- 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)
560
627
  const parsedMode = Mode.parse(mode)
561
628
  const { fields } = self
562
629
 
563
- const formAtoms: FormAtoms.FormAtoms<TFields, R, A, E, SubmitArgs> = FormAtoms.make({
630
+ const formAtoms = FormAtoms.make({
564
631
  formBuilder: self,
565
632
  runtime,
566
633
  onSubmit,
@@ -568,18 +635,21 @@ export const build = <
568
635
 
569
636
  const {
570
637
  combinedSchema,
571
- crossFieldErrorsAtom,
572
638
  dirtyFieldsAtom,
639
+ errorsAtom,
573
640
  fieldRefs,
574
641
  getFieldAtom,
575
642
  getOrCreateFieldAtoms,
576
643
  getOrCreateValidationAtom,
577
644
  hasChangedSinceSubmitAtom,
578
645
  isDirtyAtom,
646
+ keepAliveActiveAtom,
579
647
  lastSubmittedValuesAtom,
648
+ mountAtom,
580
649
  operations,
581
650
  resetAtom,
582
651
  revertToLastSubmitAtom,
652
+ rootErrorAtom,
583
653
  setValue,
584
654
  setValuesAtom,
585
655
  stateAtom,
@@ -589,7 +659,7 @@ export const build = <
589
659
  } = formAtoms
590
660
 
591
661
  const InitializeComponent: React.FC<{
592
- readonly defaultValues: Field.EncodedFromFields<TFields>
662
+ readonly defaultValues: any
593
663
  readonly children: React.ReactNode
594
664
  }> = ({ children, defaultValues }) => {
595
665
  const registry = React.useContext(RegistryContext)
@@ -599,56 +669,110 @@ export const build = <
599
669
  const isInitializedRef = React.useRef(false)
600
670
 
601
671
  React.useEffect(() => {
602
- 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
+
603
681
  isInitializedRef.current = true
604
682
  // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
605
- }, [])
683
+ }, [registry])
606
684
 
607
685
  const debouncedAutoSubmit = useDebounced(() => {
608
686
  const stateOption = registry.get(stateAtom)
609
687
  if (Option.isNone(stateOption)) return
610
- callSubmit(undefined as SubmitArgs)
688
+ callSubmit(undefined)
611
689
  }, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
612
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
+
613
706
  useAtomSubscribe(
614
707
  stateAtom,
615
708
  React.useCallback(() => {
616
709
  if (!isInitializedRef.current) return
617
- 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 {
618
726
  debouncedAutoSubmit()
619
727
  }
620
- }, [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
+ ),
621
752
  { immediate: false },
622
753
  )
623
754
 
624
755
  const onBlurAutoSubmit = React.useCallback(() => {
625
- if (parsedMode.autoSubmit && parsedMode.validation === "onBlur") {
626
- const stateOption = registry.get(stateAtom)
627
- if (Option.isNone(stateOption)) return
628
- callSubmit(undefined as SubmitArgs)
629
- }
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)
630
765
  }, [registry, callSubmit])
631
766
 
632
767
  if (Option.isNone(state)) return null
633
768
 
634
- return (
635
- <AutoSubmitContext.Provider value={onBlurAutoSubmit}>
636
- <form
637
- onSubmit={(e) => {
638
- e.preventDefault()
639
- e.stopPropagation()
640
- }}
641
- >
642
- {children}
643
- </form>
644
- </AutoSubmitContext.Provider>
645
- )
769
+ return <AutoSubmitContext.Provider value={onBlurAutoSubmit}>{children}</AutoSubmitContext.Provider>
646
770
  }
647
771
 
648
772
  const fieldComponents = makeFieldComponents(
649
773
  fields,
650
774
  stateAtom,
651
- crossFieldErrorsAtom,
775
+ errorsAtom,
652
776
  submitCountAtom,
653
777
  dirtyFieldsAtom,
654
778
  parsedMode,
@@ -658,12 +782,25 @@ export const build = <
658
782
  components,
659
783
  )
660
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
+
661
797
  return {
662
798
  values: valuesAtom,
663
799
  isDirty: isDirtyAtom,
664
800
  hasChangedSinceSubmit: hasChangedSinceSubmitAtom,
665
801
  lastSubmittedValues: lastSubmittedValuesAtom,
666
802
  submitCount: submitCountAtom,
803
+ rootError: rootErrorAtom,
667
804
  schema: combinedSchema,
668
805
  fields: fieldRefs,
669
806
  Initialize: InitializeComponent,
@@ -673,47 +810,109 @@ export const build = <
673
810
  setValues: setValuesAtom,
674
811
  setValue,
675
812
  getFieldAtom,
813
+ mount: mountAtom,
814
+ KeepAlive,
676
815
  ...fieldComponents,
677
- } as BuiltForm<TFields, R, A, E, SubmitArgs, CM>
816
+ }
678
817
  }
679
818
 
680
819
  /**
681
- * A curried helper that infers the schema type from a field definition or field reference.
820
+ * A curried helper that infers the schema type from a field definition.
682
821
  * Provides ergonomic type inference when defining field components.
683
822
  *
684
823
  * @example
685
824
  * ```tsx
686
- * import { FormReact } from "@lucas-barake/effect-form-react"
825
+ * import { Field, FormReact } from "@lucas-barake/effect-form-react"
687
826
  *
688
- * // Using a FieldRef from the built form
689
- * const TextInput = FormReact.forField(form.fields.email)(({ field }) => (
690
- * <input value={field.value} onChange={e => field.onChange(e.target.value)} />
691
- * ))
692
- *
693
- * // Using a FieldDef (for reusable fields)
694
827
  * const EmailField = Field.makeField("email", Schema.String)
695
828
  * const TextInput = FormReact.forField(EmailField)(({ field }) => (
696
829
  * <input value={field.value} onChange={e => field.onChange(e.target.value)} />
697
830
  * ))
698
831
  *
699
832
  * // With extra props - just specify the props type
700
- * const TextInput = FormReact.forField(form.fields.email)<{ placeholder?: string }>(({ field, props }) => (
833
+ * const TextInput = FormReact.forField(EmailField)<{ placeholder?: string }>(({ field, props }) => (
701
834
  * <input value={field.value} placeholder={props.placeholder} ... />
702
835
  * ))
703
836
  * ```
704
837
  *
705
838
  * @category Constructors
706
839
  */
707
- export const forField: {
708
- <S>(
709
- _field: FormBuilder.FieldRef<S>,
710
- ): <P extends Record<string, unknown> = Record<string, never>>(
711
- component: React.FC<FieldComponentProps<Schema.Schema<S, S, never>, P>>,
712
- ) => React.FC<FieldComponentProps<Schema.Schema<S, S, never>, P>>
713
-
714
- <K extends string, S extends Schema.Schema.Any>(
715
- _field: Field.FieldDef<K, S>,
716
- ): <P extends Record<string, unknown> = Record<string, never>>(
717
- component: React.FC<FieldComponentProps<S, P>>,
718
- ) => React.FC<FieldComponentProps<S, P>>
719
- } = (_field: unknown) => (component: unknown) => component as any
840
+ export const forField = <K extends string, S extends Schema.Schema.Any>(
841
+ _field: Field.FieldDef<K, S>,
842
+ ): <P extends Record<string, unknown> = Record<string, never>>(
843
+ component: React.FC<FieldComponentProps<S, P>>,
844
+ ) => React.FC<FieldComponentProps<S, P>> =>
845
+ (component) => component
846
+
847
+ /**
848
+ * Creates a bundled field definition + component for reusable form fields.
849
+ * Reduces boilerplate when you need both a field and its component together.
850
+ *
851
+ * Uses a curried API for better type inference - the schema type is captured
852
+ * first, so you only need to specify the extra props type (if any).
853
+ *
854
+ * @example
855
+ * ```tsx
856
+ * import { FormReact } from "@lucas-barake/effect-form-react"
857
+ * import * as Schema from "effect/Schema"
858
+ *
859
+ * // Define field + component in one place (no extra props)
860
+ * const NameInput = FormReact.makeField({
861
+ * key: "name",
862
+ * schema: Schema.String.pipe(Schema.nonEmptyString()),
863
+ * })(({ field }) => (
864
+ * <input
865
+ * value={field.value}
866
+ * onChange={(e) => field.onChange(e.target.value)}
867
+ * onBlur={field.onBlur}
868
+ * />
869
+ * ))
870
+ *
871
+ * // With extra props - specify only the props type
872
+ * const EmailInput = FormReact.makeField({
873
+ * key: "email",
874
+ * schema: Schema.String,
875
+ * })<{ placeholder: string }>(({ field, props }) => (
876
+ * <input
877
+ * value={field.value}
878
+ * onChange={(e) => field.onChange(e.target.value)}
879
+ * placeholder={props.placeholder}
880
+ * />
881
+ * ))
882
+ *
883
+ * // Use in form builder
884
+ * const formBuilder = FormBuilder.empty.addField(NameInput.field)
885
+ *
886
+ * // Use in make()
887
+ * const form = FormReact.make(formBuilder, {
888
+ * runtime,
889
+ * fields: { name: NameInput },
890
+ * onSubmit: (_, { decoded }) => Effect.log(decoded.name),
891
+ * })
892
+ * ```
893
+ *
894
+ * @category Constructors
895
+ */
896
+ export const makeField = <K extends string, S extends Schema.Schema.Any>(options: {
897
+ readonly key: K
898
+ readonly schema: S
899
+ }): <P extends Record<string, unknown> = Record<string, never>>(
900
+ component: React.FC<FieldComponentProps<S, P>>,
901
+ ) => FieldBundle<K, S, P> => {
902
+ const field = Field.makeField(options.key, options.schema)
903
+ return (component) => {
904
+ if (!component.displayName) {
905
+ const displayName = `${options.key.charAt(0).toUpperCase()}${options.key.slice(1)}Field`
906
+ try {
907
+ ;(component as any).displayName = displayName
908
+ } catch {
909
+ // Ignore - some environments freeze function properties
910
+ }
911
+ }
912
+ return {
913
+ _tag: "FieldBundle",
914
+ field,
915
+ component,
916
+ }
917
+ }
918
+ }