@lucas-barake/effect-form-react 0.4.0 → 0.6.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
@@ -2,13 +2,12 @@
2
2
  * @since 1.0.0
3
3
  */
4
4
  import { RegistryContext, useAtom, useAtomSet, useAtomSubscribe, useAtomValue } from "@effect-atom/atom-react"
5
- import * as Atom from "@effect-atom/atom/Atom"
6
- import type * as Result from "@effect-atom/atom/Result"
5
+ import type * as Atom from "@effect-atom/atom/Atom"
7
6
  import { Field, FormAtoms, Mode, Validation } from "@lucas-barake/effect-form"
8
7
  import type * as FormBuilder from "@lucas-barake/effect-form/FormBuilder"
9
- import { getNestedValue, isPathOrParentDirty, schemaPathToFieldPath } from "@lucas-barake/effect-form/internal/path"
8
+ import { getNestedValue, isPathOrParentDirty, isPathUnderRoot } from "@lucas-barake/effect-form/internal/path"
10
9
  import * as Cause from "effect/Cause"
11
- import * as Effect from "effect/Effect"
10
+ import type * as Effect from "effect/Effect"
12
11
  import * as Option from "effect/Option"
13
12
  import * as ParseResult from "effect/ParseResult"
14
13
  import type * as Schema from "effect/Schema"
@@ -18,12 +17,12 @@ import { createContext, useContext } from "react"
18
17
  import { useDebounced } from "./internal/use-debounced.js"
19
18
 
20
19
  /**
21
- * Props passed to field components.
20
+ * Form-controlled state passed to field components.
22
21
  *
23
22
  * @since 1.0.0
24
23
  * @category Models
25
24
  */
26
- export interface FieldComponentProps<S extends Schema.Schema.Any> {
25
+ export interface FieldState<S extends Schema.Schema.Any> {
27
26
  readonly value: Schema.Schema.Encoded<S>
28
27
  readonly onChange: (value: Schema.Schema.Encoded<S>) => void
29
28
  readonly onBlur: () => void
@@ -33,6 +32,30 @@ export interface FieldComponentProps<S extends Schema.Schema.Any> {
33
32
  readonly isDirty: boolean
34
33
  }
35
34
 
35
+ /**
36
+ * Props passed to field components.
37
+ * Contains form-controlled state in `field` and user-defined props in `props`.
38
+ *
39
+ * @since 1.0.0
40
+ * @category Models
41
+ */
42
+ export interface FieldComponentProps<
43
+ S extends Schema.Schema.Any,
44
+ P extends Record<string, unknown> = Record<string, never>,
45
+ > {
46
+ readonly field: FieldState<S>
47
+ readonly props: P
48
+ }
49
+
50
+ /**
51
+ * Extracts the extra props type from a field component.
52
+ *
53
+ * @since 1.0.0
54
+ * @category Type-level utilities
55
+ */
56
+ export type ExtractExtraProps<C> = C extends React.FC<FieldComponentProps<any, infer P>> ? P
57
+ : Record<string, never>
58
+
36
59
  /**
37
60
  * Extracts field component map for array item schemas.
38
61
  * - For Struct schemas: returns a map of field names to components
@@ -42,9 +65,10 @@ export interface FieldComponentProps<S extends Schema.Schema.Any> {
42
65
  * @category Models
43
66
  */
44
67
  export type ArrayItemComponentMap<S extends Schema.Schema.Any> = S extends Schema.Struct<infer Fields> ? {
45
- readonly [K in keyof Fields]: Fields[K] extends Schema.Schema.Any ? React.FC<FieldComponentProps<Fields[K]>> : never
68
+ readonly [K in keyof Fields]: Fields[K] extends Schema.Schema.Any ? React.FC<FieldComponentProps<Fields[K], any>>
69
+ : never
46
70
  }
47
- : React.FC<FieldComponentProps<S>>
71
+ : React.FC<FieldComponentProps<S, any>>
48
72
 
49
73
  /**
50
74
  * Maps field names to their React components.
@@ -53,7 +77,7 @@ export type ArrayItemComponentMap<S extends Schema.Schema.Any> = S extends Schem
53
77
  * @category Models
54
78
  */
55
79
  export type FieldComponentMap<TFields extends Field.FieldsRecord> = {
56
- readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S> ? React.FC<FieldComponentProps<S>>
80
+ readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, infer S> ? React.FC<FieldComponentProps<S, any>>
57
81
  : TFields[K] extends Field.ArrayFieldDef<any, infer S> ? ArrayItemComponentMap<S>
58
82
  : never
59
83
  }
@@ -80,25 +104,6 @@ export interface ArrayFieldOperations<TItem> {
80
104
  readonly move: (from: number, to: number) => void
81
105
  }
82
106
 
83
- /**
84
- * State exposed to form.Subscribe render prop.
85
- *
86
- * @since 1.0.0
87
- * @category Models
88
- */
89
- export interface SubscribeState<TFields extends Field.FieldsRecord> {
90
- readonly values: Field.EncodedFromFields<TFields>
91
- readonly isDirty: boolean
92
- readonly hasChangedSinceSubmit: boolean
93
- readonly lastSubmittedValues: Option.Option<Field.EncodedFromFields<TFields>>
94
- readonly submitResult: Result.Result<unknown, unknown>
95
- readonly submit: () => void
96
- readonly reset: () => void
97
- readonly revertToLastSubmit: () => void
98
- readonly setValue: <S>(field: FormBuilder.FieldRef<S>, update: S | ((prev: S) => S)) => void
99
- readonly setValues: (values: Field.EncodedFromFields<TFields>) => void
100
- }
101
-
102
107
  /**
103
108
  * The result of building a form, containing all components and utilities needed
104
109
  * for form rendering and submission.
@@ -106,53 +111,47 @@ export interface SubscribeState<TFields extends Field.FieldsRecord> {
106
111
  * @since 1.0.0
107
112
  * @category Models
108
113
  */
109
- export type BuiltForm<TFields extends Field.FieldsRecord, R> = {
110
- readonly atom: Atom.Writable<
111
- Option.Option<FormBuilder.FormState<TFields>>,
112
- Option.Option<FormBuilder.FormState<TFields>>
113
- >
114
+ export type BuiltForm<
115
+ TFields extends Field.FieldsRecord,
116
+ R,
117
+ A = void,
118
+ E = never,
119
+ CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
120
+ > = {
121
+ // Atoms for fine-grained subscriptions (use with useAtomValue)
122
+ readonly isDirty: Atom.Atom<boolean>
123
+ readonly hasChangedSinceSubmit: Atom.Atom<boolean>
124
+ readonly lastSubmittedValues: Atom.Atom<Option.Option<Field.EncodedFromFields<TFields>>>
125
+ readonly submitCount: Atom.Atom<number>
126
+
114
127
  readonly schema: Schema.Schema<Field.DecodedFromFields<TFields>, Field.EncodedFromFields<TFields>, R>
115
128
  readonly fields: FieldRefs<TFields>
116
129
 
117
- readonly Form: React.FC<{
130
+ readonly Initialize: React.FC<{
118
131
  readonly defaultValues: Field.EncodedFromFields<TFields>
119
- readonly onSubmit: Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown>
120
132
  readonly children: React.ReactNode
121
133
  }>
122
134
 
123
- readonly Subscribe: React.FC<{
124
- readonly children: (state: SubscribeState<TFields>) => React.ReactNode
125
- }>
126
-
127
- readonly useForm: () => {
128
- readonly submit: () => void
129
- readonly reset: () => void
130
- readonly revertToLastSubmit: () => void
131
- readonly isDirty: boolean
132
- readonly hasChangedSinceSubmit: boolean
133
- readonly lastSubmittedValues: Option.Option<Field.EncodedFromFields<TFields>>
134
- readonly submitResult: Result.Result<unknown, unknown>
135
- readonly values: Field.EncodedFromFields<TFields>
136
- readonly setValue: <S>(field: FormBuilder.FieldRef<S>, update: S | ((prev: S) => S)) => void
137
- readonly setValues: (values: Field.EncodedFromFields<TFields>) => void
138
- }
139
-
140
- readonly submit: <A>(
141
- fn: (values: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A,
142
- ) => Atom.AtomResultFn<
143
- Field.DecodedFromFields<TFields>,
144
- A extends Effect.Effect<infer T, any, any> ? T : A,
145
- A extends Effect.Effect<any, infer E, any> ? E : never
146
- >
147
- } & FieldComponents<TFields>
148
-
149
- type FieldComponents<TFields extends Field.FieldsRecord> = {
150
- readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, any> ? React.FC
151
- : TFields[K] extends Field.ArrayFieldDef<any, infer S> ? ArrayFieldComponent<S>
135
+ readonly submit: Atom.AtomResultFn<void, A, E | ParseResult.ParseError>
136
+ readonly reset: Atom.Writable<void, void>
137
+ readonly revertToLastSubmit: Atom.Writable<void, void>
138
+ readonly setValues: Atom.Writable<void, Field.EncodedFromFields<TFields>>
139
+ readonly setValue: <S>(field: FormBuilder.FieldRef<S>) => Atom.Writable<void, S | ((prev: S) => S)>
140
+ } & FieldComponents<TFields, CM>
141
+
142
+ type FieldComponents<TFields extends Field.FieldsRecord, CM extends FieldComponentMap<TFields>> = {
143
+ readonly [K in keyof TFields]: TFields[K] extends Field.FieldDef<any, any> ? React.FC<ExtractExtraProps<CM[K]>>
144
+ : TFields[K] extends Field.ArrayFieldDef<any, infer S>
145
+ ? ArrayFieldComponent<S, ExtractArrayItemExtraProps<CM[K], S>>
152
146
  : never
153
147
  }
154
148
 
155
- type ArrayFieldComponent<S extends Schema.Schema.Any> =
149
+ type ExtractArrayItemExtraProps<CM, S extends Schema.Schema.Any> = S extends Schema.Struct<infer Fields>
150
+ ? { readonly [K in keyof Fields]: CM extends { readonly [P in K]: infer C } ? ExtractExtraProps<C> : never }
151
+ : CM extends React.FC<FieldComponentProps<any, infer P>> ? P
152
+ : never
153
+
154
+ type ArrayFieldComponent<S extends Schema.Schema.Any, ExtraPropsMap> =
156
155
  & React.FC<{
157
156
  readonly children: (ops: ArrayFieldOperations<Schema.Schema.Encoded<S>>) => React.ReactNode
158
157
  }>
@@ -162,7 +161,11 @@ type ArrayFieldComponent<S extends Schema.Schema.Any> =
162
161
  readonly children: React.ReactNode | ((props: { readonly remove: () => void }) => React.ReactNode)
163
162
  }>
164
163
  }
165
- & (S extends Schema.Struct<infer Fields> ? { readonly [K in keyof Fields]: React.FC }
164
+ & (S extends Schema.Struct<infer Fields> ? {
165
+ readonly [K in keyof Fields]: React.FC<
166
+ ExtraPropsMap extends { readonly [P in K]: infer EP } ? EP : Record<string, never>
167
+ >
168
+ }
166
169
  : unknown)
167
170
 
168
171
  interface ArrayItemContextValue {
@@ -173,7 +176,7 @@ interface ArrayItemContextValue {
173
176
  const ArrayItemContext = createContext<ArrayItemContextValue | null>(null)
174
177
  const AutoSubmitContext = createContext<(() => void) | null>(null)
175
178
 
176
- const makeFieldComponent = <S extends Schema.Schema.Any>(
179
+ const makeFieldComponent = <S extends Schema.Schema.Any, P extends Record<string, unknown>>(
177
180
  fieldKey: string,
178
181
  fieldDef: Field.FieldDef<string, S>,
179
182
  crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
@@ -185,9 +188,9 @@ const makeFieldComponent = <S extends Schema.Schema.Any>(
185
188
  schema: Schema.Schema.Any,
186
189
  ) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
187
190
  getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
188
- Component: React.FC<FieldComponentProps<S>>,
189
- ): React.FC => {
190
- const FieldComponent: React.FC = React.memo(() => {
191
+ Component: React.FC<FieldComponentProps<S, P>>,
192
+ ): React.FC<P> => {
193
+ const FieldComponent: React.FC<P> = (extraProps) => {
191
194
  const arrayCtx = useContext(ArrayItemContext)
192
195
  const autoSubmitOnBlur = useContext(AutoSubmitContext)
193
196
  const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
@@ -246,12 +249,13 @@ const makeFieldComponent = <S extends Schema.Schema.Any>(
246
249
  (newValue: Schema.Schema.Encoded<S>) => {
247
250
  setValue(newValue)
248
251
  setCrossFieldErrors((prev) => {
249
- if (prev.has(fieldPath)) {
250
- const next = new Map(prev)
251
- next.delete(fieldPath)
252
- return next
252
+ const next = new Map<string, string>()
253
+ for (const [errorPath, message] of prev) {
254
+ if (!isPathUnderRoot(errorPath, fieldPath)) {
255
+ next.set(errorPath, message)
256
+ }
253
257
  }
254
- return prev
258
+ return next.size !== prev.size ? next : prev
255
259
  })
256
260
  if (parsedMode.validation === "onChange") {
257
261
  validate(newValue)
@@ -276,20 +280,20 @@ const makeFieldComponent = <S extends Schema.Schema.Any>(
276
280
  const isValidating = validationResult.waiting
277
281
  const shouldShowError = isTouched || submitCount > 0
278
282
 
279
- return (
280
- <Component
281
- value={value}
282
- onChange={onChange}
283
- onBlur={onBlur}
284
- error={shouldShowError ? validationError : Option.none<string>()}
285
- isTouched={isTouched}
286
- isValidating={isValidating}
287
- isDirty={isDirty}
288
- />
289
- )
290
- })
283
+ const fieldState: FieldState<S> = React.useMemo(() => ({
284
+ value,
285
+ onChange,
286
+ onBlur,
287
+ error: shouldShowError ? validationError : Option.none<string>(),
288
+ isTouched,
289
+ isValidating,
290
+ isDirty,
291
+ }), [value, onChange, onBlur, shouldShowError, validationError, isTouched, isValidating, isDirty])
291
292
 
292
- return FieldComponent
293
+ return <Component field={fieldState} props={extraProps} />
294
+ }
295
+
296
+ return React.memo(FieldComponent) as React.FC<P>
293
297
  }
294
298
 
295
299
  const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
@@ -307,7 +311,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
307
311
  getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
308
312
  operations: FormAtoms.FormOperations<any>,
309
313
  componentMap: ArrayItemComponentMap<S>,
310
- ): ArrayFieldComponent<S> => {
314
+ ): ArrayFieldComponent<S, any> => {
311
315
  const isStructSchema = AST.isTypeLiteral(def.itemSchema.ast)
312
316
 
313
317
  const ArrayWrapper: React.FC<{
@@ -398,7 +402,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
398
402
  const itemKey = prop.name as string
399
403
  const itemSchema = { ast: prop.type } as Schema.Schema.Any
400
404
  const itemDef = Field.makeField(itemKey, itemSchema)
401
- const itemComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any>>>)[itemKey]
405
+ const itemComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any, any>>>)[itemKey]
402
406
  itemFieldComponents[itemKey] = makeFieldComponent(
403
407
  itemKey,
404
408
  itemDef,
@@ -418,6 +422,7 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
418
422
  ...itemFieldComponents,
419
423
  }
420
424
 
425
+ // Proxy enables <Form.items.Item> and <Form.items.name> syntax
421
426
  return new Proxy(ArrayWrapper, {
422
427
  get(target, prop) {
423
428
  if (prop in properties) {
@@ -425,10 +430,13 @@ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
425
430
  }
426
431
  return Reflect.get(target, prop)
427
432
  },
428
- }) as ArrayFieldComponent<S>
433
+ }) as ArrayFieldComponent<S, any>
429
434
  }
430
435
 
431
- const makeFieldComponents = <TFields extends Field.FieldsRecord>(
436
+ const makeFieldComponents = <
437
+ TFields extends Field.FieldsRecord,
438
+ CM extends FieldComponentMap<TFields>,
439
+ >(
432
440
  fields: TFields,
433
441
  stateAtom: Atom.Writable<
434
442
  Option.Option<FormBuilder.FormState<TFields>>,
@@ -444,8 +452,8 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
444
452
  ) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
445
453
  getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
446
454
  operations: FormAtoms.FormOperations<TFields>,
447
- componentMap: FieldComponentMap<TFields>,
448
- ): FieldComponents<TFields> => {
455
+ componentMap: CM,
456
+ ): FieldComponents<TFields, CM> => {
449
457
  const components: Record<string, any> = {}
450
458
 
451
459
  for (const [key, def] of Object.entries(fields)) {
@@ -465,7 +473,7 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
465
473
  arrayComponentMap,
466
474
  )
467
475
  } else if (Field.isFieldDef(def)) {
468
- const fieldComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any>>>)[key]
476
+ const fieldComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any, any>>>)[key]
469
477
  components[key] = makeFieldComponent(
470
478
  key,
471
479
  def,
@@ -480,7 +488,7 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
480
488
  }
481
489
  }
482
490
 
483
- return components as FieldComponents<TFields>
491
+ return components as FieldComponents<TFields, CM>
484
492
  }
485
493
 
486
494
  /**
@@ -488,11 +496,11 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
488
496
  *
489
497
  * @example
490
498
  * ```tsx
491
- * import { Form } from "@lucas-barake/effect-form"
499
+ * import { FormBuilder } from "@lucas-barake/effect-form"
492
500
  * import { FormReact } from "@lucas-barake/effect-form-react"
501
+ * import { useAtomValue, useAtomSet } from "@effect-atom/atom-react"
493
502
  * import * as Atom from "@effect-atom/atom/Atom"
494
503
  * import * as Schema from "effect/Schema"
495
- * import * as Effect from "effect/Effect"
496
504
  * import * as Layer from "effect/Layer"
497
505
  *
498
506
  * const runtime = Atom.runtime(Layer.empty)
@@ -504,26 +512,28 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
504
512
  * const form = FormReact.build(loginForm, {
505
513
  * runtime,
506
514
  * fields: { email: TextInput, password: PasswordInput },
515
+ * onSubmit: (values) => Effect.log(`Login: ${values.email}`),
507
516
  * })
508
517
  *
509
- * function LoginDialog({ onClose }) {
510
- * const handleSubmit = form.submit((values) =>
511
- * Effect.gen(function* () {
512
- * yield* saveUser(values)
513
- * onClose()
514
- * })
518
+ * // Subscribe to atoms anywhere in the tree
519
+ * function SubmitButton() {
520
+ * const isDirty = useAtomValue(form.isDirty)
521
+ * const submit = useAtomValue(form.submit)
522
+ * const callSubmit = useAtomSet(form.submit)
523
+ * return (
524
+ * <button onClick={() => callSubmit()} disabled={!isDirty || submit.waiting}>
525
+ * {submit.waiting ? "Validating..." : "Login"}
526
+ * </button>
515
527
  * )
528
+ * }
516
529
  *
530
+ * function LoginDialog({ onClose }) {
517
531
  * return (
518
- * <form.Form defaultValues={{ email: "", password: "" }} onSubmit={handleSubmit}>
532
+ * <form.Initialize defaultValues={{ email: "", password: "" }}>
519
533
  * <form.email />
520
534
  * <form.password />
521
- * <form.Subscribe>
522
- * {({ isDirty, submit }) => (
523
- * <button onClick={submit} disabled={!isDirty}>Login</button>
524
- * )}
525
- * </form.Subscribe>
526
- * </form.Form>
535
+ * <SubmitButton />
536
+ * </form.Initialize>
527
537
  * )
528
538
  * }
529
539
  * ```
@@ -531,27 +541,35 @@ const makeFieldComponents = <TFields extends Field.FieldsRecord>(
531
541
  * @since 1.0.0
532
542
  * @category Constructors
533
543
  */
534
- export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
544
+ export const build = <
545
+ TFields extends Field.FieldsRecord,
546
+ R,
547
+ A,
548
+ E,
549
+ ER = never,
550
+ CM extends FieldComponentMap<TFields> = FieldComponentMap<TFields>,
551
+ >(
535
552
  self: FormBuilder.FormBuilder<TFields, R>,
536
553
  options: {
537
554
  readonly runtime: Atom.AtomRuntime<R, ER>
538
- readonly fields: FieldComponentMap<TFields>
555
+ readonly fields: CM
539
556
  readonly mode?: Mode.FormMode
557
+ readonly onSubmit: (decoded: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A | Effect.Effect<A, E, R>
540
558
  },
541
- ): BuiltForm<TFields, R> => {
542
- const { fields: components, mode, runtime } = options
559
+ ): BuiltForm<TFields, R, A, E, CM> => {
560
+ const { fields: components, mode, onSubmit, runtime } = options
543
561
  const parsedMode = Mode.parse(mode)
544
562
  const { fields } = self
545
563
 
546
- const formAtoms: FormAtoms.FormAtoms<TFields, R> = FormAtoms.make({
564
+ const formAtoms: FormAtoms.FormAtoms<TFields, R, A, E> = FormAtoms.make({
547
565
  formBuilder: self,
548
566
  runtime,
567
+ onSubmit,
549
568
  })
550
569
 
551
570
  const {
552
571
  combinedSchema,
553
572
  crossFieldErrorsAtom,
554
- decodeAndSubmit,
555
573
  dirtyFieldsAtom,
556
574
  fieldRefs,
557
575
  getOrCreateFieldAtoms,
@@ -559,42 +577,47 @@ export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
559
577
  hasChangedSinceSubmitAtom,
560
578
  isDirtyAtom,
561
579
  lastSubmittedValuesAtom,
562
- onSubmitAtom,
563
580
  operations,
564
- resetValidationAtoms,
581
+ resetAtom,
582
+ revertToLastSubmitAtom,
583
+ setValue,
584
+ setValuesAtom,
565
585
  stateAtom,
586
+ submitAtom,
566
587
  submitCountAtom,
567
588
  } = formAtoms
568
589
 
569
- const FormComponent: React.FC<{
590
+ const InitializeComponent: React.FC<{
570
591
  readonly defaultValues: Field.EncodedFromFields<TFields>
571
- readonly onSubmit: Atom.AtomResultFn<Field.DecodedFromFields<TFields>, unknown, unknown>
572
592
  readonly children: React.ReactNode
573
- }> = ({ children, defaultValues, onSubmit }) => {
593
+ }> = ({ children, defaultValues }) => {
574
594
  const registry = React.useContext(RegistryContext)
575
595
  const state = useAtomValue(stateAtom)
576
596
  const setFormState = useAtomSet(stateAtom)
577
- const setOnSubmit = useAtomSet(onSubmitAtom)
578
- const callDecodeAndSubmit = useAtomSet(decodeAndSubmit)
579
-
580
- React.useEffect(() => {
581
- setOnSubmit(onSubmit)
582
- }, [onSubmit, setOnSubmit])
597
+ const callSubmit = useAtomSet(submitAtom)
598
+ // Prevents auto-submit from firing on mount when initial state is set
599
+ const isInitializedRef = React.useRef(false)
583
600
 
584
601
  React.useEffect(() => {
585
602
  setFormState(Option.some(operations.createInitialState(defaultValues)))
603
+ // Microtask ensures state update completes before enabling auto-submit
604
+ queueMicrotask(() => {
605
+ isInitializedRef.current = true
606
+ })
586
607
  // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
587
608
  }, [])
588
609
 
589
610
  const debouncedAutoSubmit = useDebounced(() => {
590
611
  const stateOption = registry.get(stateAtom)
591
612
  if (Option.isNone(stateOption)) return
592
- callDecodeAndSubmit(stateOption.value.values)
613
+ callSubmit()
593
614
  }, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
594
615
 
595
616
  useAtomSubscribe(
596
617
  stateAtom,
597
618
  React.useCallback(() => {
619
+ // Skip auto-submit for initial state set
620
+ if (!isInitializedRef.current) return
598
621
  if (parsedMode.autoSubmit && parsedMode.validation === "onChange") {
599
622
  debouncedAutoSubmit()
600
623
  }
@@ -606,9 +629,9 @@ export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
606
629
  if (parsedMode.autoSubmit && parsedMode.validation === "onBlur") {
607
630
  const stateOption = registry.get(stateAtom)
608
631
  if (Option.isNone(stateOption)) return
609
- callDecodeAndSubmit(stateOption.value.values)
632
+ callSubmit()
610
633
  }
611
- }, [registry, callDecodeAndSubmit])
634
+ }, [registry, callSubmit])
612
635
 
613
636
  if (Option.isNone(state)) return null
614
637
 
@@ -626,175 +649,6 @@ export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
626
649
  )
627
650
  }
628
651
 
629
- const useFormHook = () => {
630
- const registry = React.useContext(RegistryContext)
631
- const formValues = Option.getOrThrow(useAtomValue(stateAtom)).values
632
- const setFormState = useAtomSet(stateAtom)
633
- const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
634
- const [decodeAndSubmitResult, callDecodeAndSubmit] = useAtom(decodeAndSubmit)
635
- const isDirty = useAtomValue(isDirtyAtom)
636
- const hasChangedSinceSubmit = useAtomValue(hasChangedSinceSubmitAtom)
637
- const lastSubmittedValues = useAtomValue(lastSubmittedValuesAtom)
638
-
639
- React.useEffect(() => {
640
- if (decodeAndSubmitResult._tag === "Failure") {
641
- const parseError = Cause.failureOption(decodeAndSubmitResult.cause)
642
- if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
643
- const issues = ParseResult.ArrayFormatter.formatErrorSync(parseError.value)
644
-
645
- const fieldErrors = new Map<string, string>()
646
- for (const issue of issues) {
647
- if (issue.path.length > 0) {
648
- const fieldPath = schemaPathToFieldPath(issue.path)
649
- if (!fieldErrors.has(fieldPath)) {
650
- fieldErrors.set(fieldPath, issue.message)
651
- }
652
- }
653
- }
654
-
655
- if (fieldErrors.size > 0) {
656
- setCrossFieldErrors(fieldErrors)
657
- }
658
- }
659
- }
660
- }, [decodeAndSubmitResult, setCrossFieldErrors])
661
-
662
- const submit = React.useCallback(() => {
663
- const stateOption = registry.get(stateAtom)
664
- if (Option.isNone(stateOption)) return
665
-
666
- setCrossFieldErrors(new Map())
667
-
668
- setFormState((prev) => {
669
- if (Option.isNone(prev)) return prev
670
- return Option.some(operations.createSubmitState(prev.value))
671
- })
672
-
673
- callDecodeAndSubmit(stateOption.value.values)
674
- }, [setFormState, callDecodeAndSubmit, setCrossFieldErrors, registry])
675
-
676
- const reset = React.useCallback(() => {
677
- setFormState((prev) => {
678
- if (Option.isNone(prev)) return prev
679
- return Option.some(operations.createResetState(prev.value))
680
- })
681
- setCrossFieldErrors(new Map())
682
- resetValidationAtoms(registry)
683
- callDecodeAndSubmit(Atom.Reset)
684
- }, [setFormState, setCrossFieldErrors, callDecodeAndSubmit, registry])
685
-
686
- const revertToLastSubmit = React.useCallback(() => {
687
- setFormState((prev) => {
688
- if (Option.isNone(prev)) return prev
689
- return Option.some(operations.revertToLastSubmit(prev.value))
690
- })
691
- setCrossFieldErrors(new Map())
692
- }, [setFormState, setCrossFieldErrors])
693
-
694
- const setValue = React.useCallback(<S,>(
695
- field: FormBuilder.FieldRef<S>,
696
- update: S | ((prev: S) => S),
697
- ) => {
698
- const path = field.key
699
-
700
- setFormState((prev) => {
701
- if (Option.isNone(prev)) return prev
702
- const state = prev.value
703
-
704
- const currentValue = getNestedValue(state.values, path) as S
705
- const newValue = typeof update === "function"
706
- ? (update as (prev: S) => S)(currentValue)
707
- : update
708
-
709
- return Option.some(operations.setFieldValue(state, path, newValue))
710
- })
711
-
712
- setCrossFieldErrors((prev) => {
713
- let changed = false
714
- const next = new Map(prev)
715
- for (const errorPath of prev.keys()) {
716
- if (errorPath === path || errorPath.startsWith(path + ".") || errorPath.startsWith(path + "[")) {
717
- next.delete(errorPath)
718
- changed = true
719
- }
720
- }
721
- return changed ? next : prev
722
- })
723
- }, [setFormState, setCrossFieldErrors])
724
-
725
- const setValues = React.useCallback((values: Field.EncodedFromFields<TFields>) => {
726
- setFormState((prev) => {
727
- if (Option.isNone(prev)) return prev
728
- return Option.some(operations.setFormValues(prev.value, values))
729
- })
730
-
731
- setCrossFieldErrors(new Map())
732
- }, [setFormState, setCrossFieldErrors])
733
-
734
- return {
735
- submit,
736
- reset,
737
- revertToLastSubmit,
738
- isDirty,
739
- hasChangedSinceSubmit,
740
- lastSubmittedValues,
741
- submitResult: decodeAndSubmitResult,
742
- values: formValues,
743
- setValue,
744
- setValues,
745
- }
746
- }
747
-
748
- const SubscribeComponent: React.FC<{
749
- readonly children: (state: SubscribeState<TFields>) => React.ReactNode
750
- }> = ({ children }) => {
751
- const {
752
- hasChangedSinceSubmit,
753
- isDirty,
754
- lastSubmittedValues,
755
- reset,
756
- revertToLastSubmit,
757
- setValue,
758
- setValues,
759
- submit,
760
- submitResult,
761
- values,
762
- } = useFormHook()
763
-
764
- return (
765
- <>
766
- {children({
767
- hasChangedSinceSubmit,
768
- isDirty,
769
- lastSubmittedValues,
770
- reset,
771
- revertToLastSubmit,
772
- setValue,
773
- setValues,
774
- submit,
775
- submitResult,
776
- values,
777
- })}
778
- </>
779
- )
780
- }
781
-
782
- const submitHelper = <A,>(
783
- fn: (values: Field.DecodedFromFields<TFields>, get: Atom.FnContext) => A,
784
- ) =>
785
- runtime.fn<Field.DecodedFromFields<TFields>>()((values, get) => {
786
- const result = fn(values, get)
787
- return (Effect.isEffect(result) ? result : Effect.succeed(result)) as Effect.Effect<
788
- A extends Effect.Effect<infer T, any, any> ? T : A,
789
- A extends Effect.Effect<any, infer E, any> ? E : never,
790
- R
791
- >
792
- }) as Atom.AtomResultFn<
793
- Field.DecodedFromFields<TFields>,
794
- A extends Effect.Effect<infer T, any, any> ? T : A,
795
- A extends Effect.Effect<any, infer E, any> ? E : never
796
- >
797
-
798
652
  const fieldComponents = makeFieldComponents(
799
653
  fields,
800
654
  stateAtom,
@@ -809,13 +663,47 @@ export const build = <TFields extends Field.FieldsRecord, R, ER = never>(
809
663
  )
810
664
 
811
665
  return {
812
- atom: stateAtom,
666
+ isDirty: isDirtyAtom,
667
+ hasChangedSinceSubmit: hasChangedSinceSubmitAtom,
668
+ lastSubmittedValues: lastSubmittedValuesAtom,
669
+ submitCount: submitCountAtom,
813
670
  schema: combinedSchema,
814
671
  fields: fieldRefs,
815
- Form: FormComponent,
816
- Subscribe: SubscribeComponent,
817
- useForm: useFormHook,
818
- submit: submitHelper,
672
+ Initialize: InitializeComponent,
673
+ submit: submitAtom,
674
+ reset: resetAtom,
675
+ revertToLastSubmit: revertToLastSubmitAtom,
676
+ setValues: setValuesAtom,
677
+ setValue,
819
678
  ...fieldComponents,
820
- } as BuiltForm<TFields, R>
679
+ } as BuiltForm<TFields, R, A, E, CM>
821
680
  }
681
+
682
+ /**
683
+ * A curried helper that infers the schema type from the field definition.
684
+ * Provides ergonomic type inference when defining field components.
685
+ *
686
+ * @example
687
+ * ```tsx
688
+ * import { FormReact } from "@lucas-barake/effect-form-react"
689
+ *
690
+ * // Without extra props - schema inferred from field
691
+ * const TextInput = FormReact.forField(EmailField)(({ field }) => (
692
+ * <input value={field.value} onChange={e => field.onChange(e.target.value)} />
693
+ * ))
694
+ *
695
+ * // With extra props - just specify the props type
696
+ * const TextInput = FormReact.forField(EmailField)<{ placeholder?: string }>(({ field, props }) => (
697
+ * <input value={field.value} placeholder={props.placeholder} ... />
698
+ * ))
699
+ * ```
700
+ *
701
+ * @since 1.0.0
702
+ * @category Constructors
703
+ */
704
+ export const forField = <K extends string, S extends Schema.Schema.Any>(
705
+ _field: Field.FieldDef<K, S>,
706
+ ) =>
707
+ <P extends Record<string, unknown> = Record<string, never>>(
708
+ component: React.FC<FieldComponentProps<S, P>>,
709
+ ): React.FC<FieldComponentProps<S, P>> => component