@lucas-barake/effect-form-react 0.1.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.
@@ -0,0 +1,743 @@
1
+ /**
2
+ * @since 1.0.0
3
+ */
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"
7
+ import { Form, FormAtoms, Mode, Validation } from "@lucas-barake/effect-form"
8
+ import { getNestedValue, isPathOrParentDirty, schemaPathToFieldPath } from "@lucas-barake/effect-form/internal/path"
9
+ import * as Cause from "effect/Cause"
10
+ import type * as Effect from "effect/Effect"
11
+ import * as Option from "effect/Option"
12
+ import * as ParseResult from "effect/ParseResult"
13
+ import type * as Schema from "effect/Schema"
14
+ import * as AST from "effect/SchemaAST"
15
+ import * as React from "react"
16
+ import { createContext, useContext } from "react"
17
+ import { useDebounced } from "./internal/use-debounced.js"
18
+
19
+ /**
20
+ * Props passed to field components.
21
+ *
22
+ * @since 1.0.0
23
+ * @category Models
24
+ */
25
+ export interface FieldComponentProps<S extends Schema.Schema.Any> {
26
+ readonly value: Schema.Schema.Encoded<S>
27
+ readonly onChange: (value: Schema.Schema.Encoded<S>) => void
28
+ readonly onBlur: () => void
29
+ readonly error: Option.Option<string>
30
+ readonly isTouched: boolean
31
+ readonly isValidating: boolean
32
+ readonly isDirty: boolean
33
+ }
34
+
35
+ /**
36
+ * Extracts field component map for array item schemas.
37
+ * - For Struct schemas: returns a map of field names to components
38
+ * - For primitive schemas: returns a single component
39
+ *
40
+ * @since 1.0.0
41
+ * @category Models
42
+ */
43
+ export type ArrayItemComponentMap<S extends Schema.Schema.Any> = S extends Schema.Struct<infer Fields> ? {
44
+ readonly [K in keyof Fields]: Fields[K] extends Schema.Schema.Any ? React.FC<FieldComponentProps<Fields[K]>> : never
45
+ }
46
+ : React.FC<FieldComponentProps<S>>
47
+
48
+ /**
49
+ * Maps field names to their React components.
50
+ *
51
+ * @since 1.0.0
52
+ * @category Models
53
+ */
54
+ export type FieldComponentMap<TFields extends Form.FieldsRecord> = {
55
+ readonly [K in keyof TFields]: TFields[K] extends Form.FieldDef<any, infer S> ? React.FC<FieldComponentProps<S>>
56
+ : TFields[K] extends Form.ArrayFieldDef<any, infer S> ? ArrayItemComponentMap<S>
57
+ : never
58
+ }
59
+
60
+ /**
61
+ * Maps field names to their type-safe Field references for setValue operations.
62
+ *
63
+ * @since 1.0.0
64
+ * @category Models
65
+ */
66
+ export type FieldRefs<TFields extends Form.FieldsRecord> = FormAtoms.FieldRefs<TFields>
67
+
68
+ /**
69
+ * Operations available for array fields.
70
+ *
71
+ * @since 1.0.0
72
+ * @category Models
73
+ */
74
+ export interface ArrayFieldOperations<TItem> {
75
+ readonly items: ReadonlyArray<TItem>
76
+ readonly append: (value?: TItem) => void
77
+ readonly remove: (index: number) => void
78
+ readonly swap: (indexA: number, indexB: number) => void
79
+ readonly move: (from: number, to: number) => void
80
+ }
81
+
82
+ /**
83
+ * State exposed to form.Subscribe render prop.
84
+ *
85
+ * @since 1.0.0
86
+ * @category Models
87
+ */
88
+ export interface SubscribeState<TFields extends Form.FieldsRecord> {
89
+ readonly values: Form.EncodedFromFields<TFields>
90
+ readonly isDirty: boolean
91
+ readonly submitResult: Result.Result<unknown, unknown>
92
+ readonly submit: () => void
93
+ readonly reset: () => void
94
+ readonly setValue: <S>(field: Form.Field<S>, update: S | ((prev: S) => S)) => void
95
+ readonly setValues: (values: Form.EncodedFromFields<TFields>) => void
96
+ }
97
+
98
+ /**
99
+ * The result of building a form, containing all components and utilities needed
100
+ * for form rendering and submission.
101
+ *
102
+ * @since 1.0.0
103
+ * @category Models
104
+ */
105
+ export type BuiltForm<TFields extends Form.FieldsRecord, R> = {
106
+ readonly atom: Atom.Writable<Option.Option<Form.FormState<TFields>>, Option.Option<Form.FormState<TFields>>>
107
+ readonly schema: Schema.Schema<Form.DecodedFromFields<TFields>, Form.EncodedFromFields<TFields>, R>
108
+ readonly fields: FieldRefs<TFields>
109
+
110
+ readonly Form: React.FC<{
111
+ readonly defaultValues: Form.EncodedFromFields<TFields>
112
+ readonly onSubmit: Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown>
113
+ readonly children: React.ReactNode
114
+ }>
115
+
116
+ readonly Subscribe: React.FC<{
117
+ readonly children: (state: SubscribeState<TFields>) => React.ReactNode
118
+ }>
119
+
120
+ readonly useForm: () => {
121
+ readonly submit: () => void
122
+ readonly reset: () => void
123
+ readonly isDirty: boolean
124
+ readonly submitResult: Result.Result<unknown, unknown>
125
+ readonly values: Form.EncodedFromFields<TFields>
126
+ readonly setValue: <S>(field: Form.Field<S>, update: S | ((prev: S) => S)) => void
127
+ readonly setValues: (values: Form.EncodedFromFields<TFields>) => void
128
+ }
129
+
130
+ readonly submit: <A, E>(
131
+ fn: (values: Form.DecodedFromFields<TFields>, get: Atom.FnContext) => Effect.Effect<A, E, R>,
132
+ ) => Atom.AtomResultFn<Form.DecodedFromFields<TFields>, A, E>
133
+ } & FieldComponents<TFields>
134
+
135
+ type FieldComponents<TFields extends Form.FieldsRecord> = {
136
+ readonly [K in keyof TFields]: TFields[K] extends Form.FieldDef<any, any> ? React.FC
137
+ : TFields[K] extends Form.ArrayFieldDef<any, infer S> ? ArrayFieldComponent<S>
138
+ : never
139
+ }
140
+
141
+ type ArrayFieldComponent<S extends Schema.Schema.Any> =
142
+ & React.FC<{
143
+ readonly children: (ops: ArrayFieldOperations<Schema.Schema.Encoded<S>>) => React.ReactNode
144
+ }>
145
+ & {
146
+ readonly Item: React.FC<{
147
+ readonly index: number
148
+ readonly children: React.ReactNode | ((props: { readonly remove: () => void }) => React.ReactNode)
149
+ }>
150
+ }
151
+ & (S extends Schema.Struct<infer Fields> ? { readonly [K in keyof Fields]: React.FC }
152
+ : unknown)
153
+
154
+ interface ArrayItemContextValue {
155
+ readonly index: number
156
+ readonly parentPath: string
157
+ }
158
+
159
+ const ArrayItemContext = createContext<ArrayItemContextValue | null>(null)
160
+ const AutoSubmitContext = createContext<(() => void) | null>(null)
161
+
162
+ const makeFieldComponent = <S extends Schema.Schema.Any>(
163
+ fieldKey: string,
164
+ fieldDef: Form.FieldDef<string, S>,
165
+ crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
166
+ submitCountAtom: Atom.Atom<number>,
167
+ dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
168
+ parsedMode: Mode.ParsedMode,
169
+ getOrCreateValidationAtom: (
170
+ fieldPath: string,
171
+ schema: Schema.Schema.Any,
172
+ ) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
173
+ getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
174
+ Component: React.FC<FieldComponentProps<S>>,
175
+ ): React.FC => {
176
+ const FieldComponent: React.FC = React.memo(() => {
177
+ const arrayCtx = useContext(ArrayItemContext)
178
+ const autoSubmitOnBlur = useContext(AutoSubmitContext)
179
+ const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
180
+
181
+ const { crossFieldErrorAtom, touchedAtom, valueAtom } = React.useMemo(
182
+ () => getOrCreateFieldAtoms(fieldPath),
183
+ [fieldPath],
184
+ )
185
+
186
+ const [value, setValue] = useAtom(valueAtom) as [Schema.Schema.Encoded<S>, (v: unknown) => void]
187
+ const [isTouched, setTouched] = useAtom(touchedAtom)
188
+ const crossFieldError = useAtomValue(crossFieldErrorAtom)
189
+ const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
190
+ const submitCount = useAtomValue(submitCountAtom)
191
+
192
+ const validationAtom = React.useMemo(
193
+ () => getOrCreateValidationAtom(fieldPath, fieldDef.schema),
194
+ [fieldPath],
195
+ )
196
+ const validationResult = useAtomValue(validationAtom)
197
+ const validateImmediate = useAtomSet(validationAtom)
198
+
199
+ const shouldDebounceValidation = parsedMode.validation === "onChange"
200
+ && parsedMode.debounce !== null
201
+ && !parsedMode.autoSubmit
202
+ const validate = useDebounced(validateImmediate, shouldDebounceValidation ? parsedMode.debounce : null)
203
+
204
+ const prevValueRef = React.useRef(value)
205
+ React.useEffect(() => {
206
+ if (prevValueRef.current === value) {
207
+ return
208
+ }
209
+ prevValueRef.current = value
210
+
211
+ const shouldValidate = parsedMode.validation === "onChange"
212
+ || (parsedMode.validation === "onBlur" && isTouched)
213
+
214
+ if (shouldValidate) {
215
+ validate(value)
216
+ }
217
+ }, [value, isTouched, validate])
218
+
219
+ const perFieldError: Option.Option<string> = React.useMemo(() => {
220
+ if (validationResult._tag === "Failure") {
221
+ const parseError = Cause.failureOption(validationResult.cause)
222
+ if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
223
+ return Validation.extractFirstError(parseError.value)
224
+ }
225
+ }
226
+ return Option.none()
227
+ }, [validationResult])
228
+
229
+ const validationError = Option.isSome(perFieldError) ? perFieldError : crossFieldError
230
+
231
+ const onChange = React.useCallback(
232
+ (newValue: Schema.Schema.Encoded<S>) => {
233
+ setValue(newValue)
234
+ setCrossFieldErrors((prev) => {
235
+ if (prev.has(fieldPath)) {
236
+ const next = new Map(prev)
237
+ next.delete(fieldPath)
238
+ return next
239
+ }
240
+ return prev
241
+ })
242
+ if (parsedMode.validation === "onChange") {
243
+ validate(newValue)
244
+ }
245
+ },
246
+ [fieldPath, setValue, setCrossFieldErrors, validate],
247
+ )
248
+
249
+ const onBlur = React.useCallback(() => {
250
+ setTouched(true)
251
+ if (parsedMode.validation === "onBlur") {
252
+ validate(value)
253
+ }
254
+ autoSubmitOnBlur?.()
255
+ }, [setTouched, validate, value, autoSubmitOnBlur])
256
+
257
+ const dirtyFields = useAtomValue(dirtyFieldsAtom)
258
+ const isDirty = React.useMemo(
259
+ () => isPathOrParentDirty(dirtyFields, fieldPath),
260
+ [dirtyFields, fieldPath],
261
+ )
262
+ const isValidating = validationResult.waiting
263
+ const shouldShowError = isTouched || submitCount > 0
264
+
265
+ return (
266
+ <Component
267
+ value={value}
268
+ onChange={onChange}
269
+ onBlur={onBlur}
270
+ error={shouldShowError ? validationError : Option.none<string>()}
271
+ isTouched={isTouched}
272
+ isValidating={isValidating}
273
+ isDirty={isDirty}
274
+ />
275
+ )
276
+ })
277
+
278
+ return FieldComponent
279
+ }
280
+
281
+ const makeArrayFieldComponent = <S extends Schema.Schema.Any>(
282
+ fieldKey: string,
283
+ def: Form.ArrayFieldDef<string, S>,
284
+ stateAtom: Atom.Writable<Option.Option<Form.FormState<any>>, Option.Option<Form.FormState<any>>>,
285
+ crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
286
+ submitCountAtom: Atom.Atom<number>,
287
+ dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
288
+ parsedMode: Mode.ParsedMode,
289
+ getOrCreateValidationAtom: (
290
+ fieldPath: string,
291
+ schema: Schema.Schema.Any,
292
+ ) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
293
+ getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
294
+ operations: FormAtoms.FormOperations<any>,
295
+ componentMap: ArrayItemComponentMap<S>,
296
+ ): ArrayFieldComponent<S> => {
297
+ const isStructSchema = AST.isTypeLiteral(def.itemSchema.ast)
298
+
299
+ const ArrayWrapper: React.FC<{
300
+ readonly children: (ops: ArrayFieldOperations<Schema.Schema.Encoded<S>>) => React.ReactNode
301
+ }> = ({ children }) => {
302
+ const arrayCtx = useContext(ArrayItemContext)
303
+ const [formStateOption, setFormState] = useAtom(stateAtom)
304
+ const formState = Option.getOrThrow(formStateOption)
305
+
306
+ const fieldPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
307
+ const items = React.useMemo(
308
+ () => (getNestedValue(formState.values, fieldPath) ?? []) as ReadonlyArray<Schema.Schema.Encoded<S>>,
309
+ [formState.values, fieldPath],
310
+ )
311
+
312
+ const append = React.useCallback(
313
+ (value?: Schema.Schema.Encoded<S>) => {
314
+ setFormState((prev) => {
315
+ if (Option.isNone(prev)) return prev
316
+ return Option.some(operations.appendArrayItem(prev.value, fieldPath, def.itemSchema, value))
317
+ })
318
+ },
319
+ [fieldPath, setFormState],
320
+ )
321
+
322
+ const remove = React.useCallback(
323
+ (index: number) => {
324
+ setFormState((prev) => {
325
+ if (Option.isNone(prev)) return prev
326
+ return Option.some(operations.removeArrayItem(prev.value, fieldPath, index))
327
+ })
328
+ },
329
+ [fieldPath, setFormState],
330
+ )
331
+
332
+ const swap = React.useCallback(
333
+ (indexA: number, indexB: number) => {
334
+ setFormState((prev) => {
335
+ if (Option.isNone(prev)) return prev
336
+ return Option.some(operations.swapArrayItems(prev.value, fieldPath, indexA, indexB))
337
+ })
338
+ },
339
+ [fieldPath, setFormState],
340
+ )
341
+
342
+ const move = React.useCallback(
343
+ (from: number, to: number) => {
344
+ setFormState((prev) => {
345
+ if (Option.isNone(prev)) return prev
346
+ return Option.some(operations.moveArrayItem(prev.value, fieldPath, from, to))
347
+ })
348
+ },
349
+ [fieldPath, setFormState],
350
+ )
351
+
352
+ return <>{children({ items, append, remove, swap, move })}</>
353
+ }
354
+
355
+ const ItemWrapper: React.FC<{
356
+ readonly index: number
357
+ readonly children: React.ReactNode | ((props: { readonly remove: () => void }) => React.ReactNode)
358
+ }> = ({ children, index }) => {
359
+ const arrayCtx = useContext(ArrayItemContext)
360
+ const setFormState = useAtomSet(stateAtom)
361
+
362
+ const parentPath = arrayCtx ? `${arrayCtx.parentPath}.${fieldKey}` : fieldKey
363
+ const itemPath = `${parentPath}[${index}]`
364
+
365
+ const remove = React.useCallback(() => {
366
+ setFormState((prev) => {
367
+ if (Option.isNone(prev)) return prev
368
+ return Option.some(operations.removeArrayItem(prev.value, parentPath, index))
369
+ })
370
+ }, [parentPath, index, setFormState])
371
+
372
+ return (
373
+ <ArrayItemContext.Provider value={{ index, parentPath: itemPath }}>
374
+ {typeof children === "function" ? children({ remove }) : children}
375
+ </ArrayItemContext.Provider>
376
+ )
377
+ }
378
+
379
+ const itemFieldComponents: Record<string, React.FC> = {}
380
+
381
+ if (isStructSchema) {
382
+ const ast = def.itemSchema.ast as AST.TypeLiteral
383
+ for (const prop of ast.propertySignatures) {
384
+ const itemKey = prop.name as string
385
+ const itemSchema = { ast: prop.type } as Schema.Schema.Any
386
+ const itemDef = Form.makeField(itemKey, itemSchema)
387
+ const itemComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any>>>)[itemKey]
388
+ itemFieldComponents[itemKey] = makeFieldComponent(
389
+ itemKey,
390
+ itemDef,
391
+ crossFieldErrorsAtom,
392
+ submitCountAtom,
393
+ dirtyFieldsAtom,
394
+ parsedMode,
395
+ getOrCreateValidationAtom,
396
+ getOrCreateFieldAtoms,
397
+ itemComponent,
398
+ )
399
+ }
400
+ }
401
+
402
+ const properties: Record<string, unknown> = {
403
+ Item: ItemWrapper,
404
+ ...itemFieldComponents,
405
+ }
406
+
407
+ return new Proxy(ArrayWrapper, {
408
+ get(target, prop) {
409
+ if (prop in properties) {
410
+ return properties[prop as string]
411
+ }
412
+ return Reflect.get(target, prop)
413
+ },
414
+ }) as ArrayFieldComponent<S>
415
+ }
416
+
417
+ const makeFieldComponents = <TFields extends Form.FieldsRecord>(
418
+ fields: TFields,
419
+ stateAtom: Atom.Writable<Option.Option<Form.FormState<TFields>>, Option.Option<Form.FormState<TFields>>>,
420
+ crossFieldErrorsAtom: Atom.Writable<Map<string, string>, Map<string, string>>,
421
+ submitCountAtom: Atom.Atom<number>,
422
+ dirtyFieldsAtom: Atom.Atom<ReadonlySet<string>>,
423
+ parsedMode: Mode.ParsedMode,
424
+ getOrCreateValidationAtom: (
425
+ fieldPath: string,
426
+ schema: Schema.Schema.Any,
427
+ ) => Atom.AtomResultFn<unknown, void, ParseResult.ParseError>,
428
+ getOrCreateFieldAtoms: (fieldPath: string) => FormAtoms.FieldAtoms,
429
+ operations: FormAtoms.FormOperations<TFields>,
430
+ componentMap: FieldComponentMap<TFields>,
431
+ ): FieldComponents<TFields> => {
432
+ const components: Record<string, any> = {}
433
+
434
+ for (const [key, def] of Object.entries(fields)) {
435
+ if (Form.isArrayFieldDef(def)) {
436
+ const arrayComponentMap = (componentMap as Record<string, any>)[key]
437
+ components[key] = makeArrayFieldComponent(
438
+ key,
439
+ def as Form.ArrayFieldDef<string, Schema.Schema.Any>,
440
+ stateAtom,
441
+ crossFieldErrorsAtom,
442
+ submitCountAtom,
443
+ dirtyFieldsAtom,
444
+ parsedMode,
445
+ getOrCreateValidationAtom,
446
+ getOrCreateFieldAtoms,
447
+ operations,
448
+ arrayComponentMap,
449
+ )
450
+ } else if (Form.isFieldDef(def)) {
451
+ const fieldComponent = (componentMap as Record<string, React.FC<FieldComponentProps<any>>>)[key]
452
+ components[key] = makeFieldComponent(
453
+ key,
454
+ def,
455
+ crossFieldErrorsAtom,
456
+ submitCountAtom,
457
+ dirtyFieldsAtom,
458
+ parsedMode,
459
+ getOrCreateValidationAtom,
460
+ getOrCreateFieldAtoms,
461
+ fieldComponent,
462
+ )
463
+ }
464
+ }
465
+
466
+ return components as FieldComponents<TFields>
467
+ }
468
+
469
+ /**
470
+ * Builds a React form from a FormBuilder.
471
+ *
472
+ * @example
473
+ * ```tsx
474
+ * import { Form } from "@lucas-barake/effect-form"
475
+ * import { FormReact } from "@lucas-barake/effect-form-react"
476
+ * import * as Atom from "@effect-atom/atom/Atom"
477
+ * import * as Schema from "effect/Schema"
478
+ * import * as Effect from "effect/Effect"
479
+ * import * as Layer from "effect/Layer"
480
+ *
481
+ * const runtime = Atom.runtime(Layer.empty)
482
+ *
483
+ * const loginForm = Form.empty
484
+ * .addField("email", Schema.String)
485
+ * .addField("password", Schema.String)
486
+ *
487
+ * const form = FormReact.build(loginForm, {
488
+ * runtime,
489
+ * fields: { email: TextInput, password: PasswordInput },
490
+ * })
491
+ *
492
+ * function LoginDialog({ onClose }) {
493
+ * const handleSubmit = form.submit((values) =>
494
+ * Effect.gen(function* () {
495
+ * yield* saveUser(values)
496
+ * onClose()
497
+ * })
498
+ * )
499
+ *
500
+ * return (
501
+ * <form.Form defaultValues={{ email: "", password: "" }} onSubmit={handleSubmit}>
502
+ * <form.email />
503
+ * <form.password />
504
+ * <form.Subscribe>
505
+ * {({ isDirty, submit }) => (
506
+ * <button onClick={submit} disabled={!isDirty}>Login</button>
507
+ * )}
508
+ * </form.Subscribe>
509
+ * </form.Form>
510
+ * )
511
+ * }
512
+ * ```
513
+ *
514
+ * @since 1.0.0
515
+ * @category Constructors
516
+ */
517
+ export const build = <TFields extends Form.FieldsRecord, R, ER = never>(
518
+ self: Form.FormBuilder<TFields, R>,
519
+ options: {
520
+ readonly runtime: Atom.AtomRuntime<R, ER>
521
+ readonly fields: FieldComponentMap<TFields>
522
+ readonly mode?: Mode.FormMode
523
+ },
524
+ ): BuiltForm<TFields, R> => {
525
+ const { fields: components, mode, runtime } = options
526
+ const parsedMode = Mode.parse(mode)
527
+ const { fields } = self
528
+
529
+ const formAtoms: FormAtoms.FormAtoms<TFields, R> = FormAtoms.make({
530
+ formBuilder: self,
531
+ runtime,
532
+ })
533
+
534
+ const {
535
+ combinedSchema,
536
+ crossFieldErrorsAtom,
537
+ decodeAndSubmit,
538
+ dirtyFieldsAtom,
539
+ fieldRefs,
540
+ getOrCreateFieldAtoms,
541
+ getOrCreateValidationAtom,
542
+ isDirtyAtom,
543
+ onSubmitAtom,
544
+ operations,
545
+ resetValidationAtoms,
546
+ stateAtom,
547
+ submitCountAtom,
548
+ } = formAtoms
549
+
550
+ const FormComponent: React.FC<{
551
+ readonly defaultValues: Form.EncodedFromFields<TFields>
552
+ readonly onSubmit: Atom.AtomResultFn<Form.DecodedFromFields<TFields>, unknown, unknown>
553
+ readonly children: React.ReactNode
554
+ }> = ({ children, defaultValues, onSubmit }) => {
555
+ const registry = React.useContext(RegistryContext)
556
+ const state = useAtomValue(stateAtom)
557
+ const setFormState = useAtomSet(stateAtom)
558
+ const setOnSubmit = useAtomSet(onSubmitAtom)
559
+ const callDecodeAndSubmit = useAtomSet(decodeAndSubmit)
560
+
561
+ React.useEffect(() => {
562
+ setOnSubmit(onSubmit)
563
+ }, [onSubmit, setOnSubmit])
564
+
565
+ React.useEffect(() => {
566
+ setFormState(Option.some(operations.createInitialState(defaultValues)))
567
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only
568
+ }, [])
569
+
570
+ const debouncedAutoSubmit = useDebounced(() => {
571
+ const stateOption = registry.get(stateAtom)
572
+ if (Option.isNone(stateOption)) return
573
+ callDecodeAndSubmit(stateOption.value.values)
574
+ }, parsedMode.autoSubmit && parsedMode.validation === "onChange" ? parsedMode.debounce : null)
575
+
576
+ useAtomSubscribe(
577
+ stateAtom,
578
+ React.useCallback(() => {
579
+ if (parsedMode.autoSubmit && parsedMode.validation === "onChange") {
580
+ debouncedAutoSubmit()
581
+ }
582
+ }, [debouncedAutoSubmit]),
583
+ { immediate: false },
584
+ )
585
+
586
+ const onBlurAutoSubmit = React.useCallback(() => {
587
+ if (parsedMode.autoSubmit && parsedMode.validation === "onBlur") {
588
+ const stateOption = registry.get(stateAtom)
589
+ if (Option.isNone(stateOption)) return
590
+ callDecodeAndSubmit(stateOption.value.values)
591
+ }
592
+ }, [registry, callDecodeAndSubmit])
593
+
594
+ if (Option.isNone(state)) return null
595
+
596
+ return (
597
+ <AutoSubmitContext.Provider value={onBlurAutoSubmit}>
598
+ <form
599
+ onSubmit={(e) => {
600
+ e.preventDefault()
601
+ e.stopPropagation()
602
+ }}
603
+ >
604
+ {children}
605
+ </form>
606
+ </AutoSubmitContext.Provider>
607
+ )
608
+ }
609
+
610
+ const useFormHook = () => {
611
+ const registry = React.useContext(RegistryContext)
612
+ const formValues = Option.getOrThrow(useAtomValue(stateAtom)).values
613
+ const setFormState = useAtomSet(stateAtom)
614
+ const setCrossFieldErrors = useAtomSet(crossFieldErrorsAtom)
615
+ const [decodeAndSubmitResult, callDecodeAndSubmit] = useAtom(decodeAndSubmit)
616
+ const isDirty = useAtomValue(isDirtyAtom)
617
+
618
+ React.useEffect(() => {
619
+ if (decodeAndSubmitResult._tag === "Failure") {
620
+ const parseError = Cause.failureOption(decodeAndSubmitResult.cause)
621
+ if (Option.isSome(parseError) && ParseResult.isParseError(parseError.value)) {
622
+ const issues = ParseResult.ArrayFormatter.formatErrorSync(parseError.value)
623
+
624
+ const fieldErrors = new Map<string, string>()
625
+ for (const issue of issues) {
626
+ if (issue.path.length > 0) {
627
+ const fieldPath = schemaPathToFieldPath(issue.path)
628
+ if (!fieldErrors.has(fieldPath)) {
629
+ fieldErrors.set(fieldPath, issue.message)
630
+ }
631
+ }
632
+ }
633
+
634
+ if (fieldErrors.size > 0) {
635
+ setCrossFieldErrors(fieldErrors)
636
+ }
637
+ }
638
+ }
639
+ }, [decodeAndSubmitResult, setCrossFieldErrors])
640
+
641
+ const submit = React.useCallback(() => {
642
+ const stateOption = registry.get(stateAtom)
643
+ if (Option.isNone(stateOption)) return
644
+
645
+ setCrossFieldErrors(new Map())
646
+
647
+ setFormState((prev) => {
648
+ if (Option.isNone(prev)) return prev
649
+ return Option.some(operations.createSubmitState(prev.value))
650
+ })
651
+
652
+ callDecodeAndSubmit(stateOption.value.values)
653
+ }, [setFormState, callDecodeAndSubmit, setCrossFieldErrors, registry])
654
+
655
+ const reset = React.useCallback(() => {
656
+ setFormState((prev) => {
657
+ if (Option.isNone(prev)) return prev
658
+ return Option.some(operations.createResetState(prev.value))
659
+ })
660
+ setCrossFieldErrors(new Map())
661
+ resetValidationAtoms(registry)
662
+ callDecodeAndSubmit(Atom.Reset)
663
+ }, [setFormState, setCrossFieldErrors, callDecodeAndSubmit, registry])
664
+
665
+ const setValue = React.useCallback(<S,>(
666
+ field: Form.Field<S>,
667
+ update: S | ((prev: S) => S),
668
+ ) => {
669
+ const path = field.key
670
+
671
+ setFormState((prev) => {
672
+ if (Option.isNone(prev)) return prev
673
+ const state = prev.value
674
+
675
+ const currentValue = getNestedValue(state.values, path) as S
676
+ const newValue = typeof update === "function"
677
+ ? (update as (prev: S) => S)(currentValue)
678
+ : update
679
+
680
+ return Option.some(operations.setFieldValue(state, path, newValue))
681
+ })
682
+
683
+ setCrossFieldErrors((prev) => {
684
+ let changed = false
685
+ const next = new Map(prev)
686
+ for (const errorPath of prev.keys()) {
687
+ if (errorPath === path || errorPath.startsWith(path + ".") || errorPath.startsWith(path + "[")) {
688
+ next.delete(errorPath)
689
+ changed = true
690
+ }
691
+ }
692
+ return changed ? next : prev
693
+ })
694
+ }, [setFormState, setCrossFieldErrors])
695
+
696
+ const setValues = React.useCallback((values: Form.EncodedFromFields<TFields>) => {
697
+ setFormState((prev) => {
698
+ if (Option.isNone(prev)) return prev
699
+ return Option.some(operations.setFormValues(prev.value, values))
700
+ })
701
+
702
+ setCrossFieldErrors(new Map())
703
+ }, [setFormState, setCrossFieldErrors])
704
+
705
+ return { submit, reset, isDirty, submitResult: decodeAndSubmitResult, values: formValues, setValue, setValues }
706
+ }
707
+
708
+ const SubscribeComponent: React.FC<{
709
+ readonly children: (state: SubscribeState<TFields>) => React.ReactNode
710
+ }> = ({ children }) => {
711
+ const { isDirty, reset, setValue, setValues, submit, submitResult, values } = useFormHook()
712
+
713
+ return <>{children({ values, isDirty, submitResult, submit, reset, setValue, setValues })}</>
714
+ }
715
+
716
+ const submitHelper = <A, E>(
717
+ fn: (values: Form.DecodedFromFields<TFields>, get: Atom.FnContext) => Effect.Effect<A, E, R>,
718
+ ) => runtime.fn<Form.DecodedFromFields<TFields>>()(fn) as Atom.AtomResultFn<Form.DecodedFromFields<TFields>, A, E>
719
+
720
+ const fieldComponents = makeFieldComponents(
721
+ fields,
722
+ stateAtom,
723
+ crossFieldErrorsAtom,
724
+ submitCountAtom,
725
+ dirtyFieldsAtom,
726
+ parsedMode,
727
+ getOrCreateValidationAtom,
728
+ getOrCreateFieldAtoms,
729
+ operations,
730
+ components,
731
+ )
732
+
733
+ return {
734
+ atom: stateAtom,
735
+ schema: combinedSchema,
736
+ fields: fieldRefs,
737
+ Form: FormComponent,
738
+ Subscribe: SubscribeComponent,
739
+ useForm: useFormHook,
740
+ submit: submitHelper,
741
+ ...fieldComponents,
742
+ } as BuiltForm<TFields, R>
743
+ }