From 7545b4bb305d91bbecce3c540e7a3ad799b1422e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 23 Sep 2025 05:28:23 +0200 Subject: [PATCH] Form V2 --- packages/effect-fc/src/Form.ts | 205 ++++++++++++--------------- packages/example/src/routes/form.tsx | 8 +- 2 files changed, 91 insertions(+), 122 deletions(-) diff --git a/packages/effect-fc/src/Form.ts b/packages/effect-fc/src/Form.ts index be66cb7..a019282 100644 --- a/packages/effect-fc/src/Form.ts +++ b/packages/effect-fc/src/Form.ts @@ -1,4 +1,4 @@ -import { Array, Effect, Equivalence, flow, Option, ParseResult, Pipeable, Schema, Stream, type Subscribable, SubscriptionRef } from "effect" +import { Array, Duration, Effect, Equivalence, flow, identity, Option, ParseResult, Pipeable, Schema, Stream, Subscribable, SubscriptionRef } from "effect" import type { NoSuchElementException } from "effect/Cause" import * as React from "react" import { Hooks } from "./hooks/index.js" @@ -10,147 +10,116 @@ export type TypeId = typeof TypeId export interface Form extends Pipeable.Pipeable { - readonly schema: Schema.Schema - readonly latestValueSubscribable: Subscribable.Subscribable + readonly schema: Schema.Schema, + readonly valueRef: SubscriptionRef.SubscriptionRef>, + readonly encodedValueRef: SubscriptionRef.SubscriptionRef, + readonly errorRef: SubscriptionRef.SubscriptionRef>, - useFieldLatestValueSubscribable

>( - path: P - ): Subscribable.Subscribable, NoSuchElementException> - useFieldIssuesSubscribable( - path: PropertyPath.Paths - ): Subscribable.Subscribable - - useInput

>( - options: Form.useInput.Options - ): Effect.Effect< - Form.useInput.Result>, - ParseResult.ParseError | NoSuchElementException, - R - > -} - -export namespace Form { - export namespace useInput { - export interface Options> { - readonly path: P - readonly defaultValue?: PropertyPath.ValueFromPath, NoInfer

> - } - - export interface Result { - readonly value: T - readonly setValue: React.Dispatch> - readonly issues: readonly ParseResult.ArrayFormatterIssue[] - } - } + makeFieldIssuesSubscribable

>(path: P): Subscribable.Subscribable } class FormImpl extends Pipeable.Class() implements Form { readonly [TypeId]: TypeId = TypeId - readonly latestValueSubscribable: Subscribable.Subscribable constructor( readonly schema: Schema.Schema, - readonly latestValueRef: SubscriptionRef.SubscriptionRef, - readonly latestCandidateRef: SubscriptionRef.SubscriptionRef, + readonly valueRef: SubscriptionRef.SubscriptionRef>, + readonly encodedValueRef: SubscriptionRef.SubscriptionRef, readonly errorRef: SubscriptionRef.SubscriptionRef>, ) { super() - this.latestValueSubscribable = latestValueRef } - useFieldLatestValueSubscribable

>( - path: P - ) { - return React.useMemo(() => { - const latestValueRef = this.latestValueRef - return SubscribableInternal.make({ - get: Effect.flatMap(latestValueRef.get, PropertyPath.get(path)), - get changes() { return Stream.flatMap(latestValueRef.changes, PropertyPath.get(path)) }, - }) - }, [this.latestValueRef, ...path]) - } + makeFieldIssuesSubscribable

>(path: P) { + const filter = Option.match({ + onSome: (v: ParseResult.ParseError) => Effect.andThen( + ParseResult.ArrayFormatter.formatError(v), + Array.filter(issue => PropertyPath.equivalence(issue.path, path)), + ), + onNone: () => Effect.succeed([]), + }) - useFieldIssuesSubscribable( - path: PropertyPath.Paths - ) { - return React.useMemo(() => { - const filter = Option.match({ - onSome: (v: ParseResult.ParseError) => Effect.andThen( - ParseResult.ArrayFormatter.formatError(v), - Array.filter(issue => PropertyPath.equivalence(issue.path, path)), - ), - onNone: () => Effect.succeed([]), - }) - - const errorRef = this.errorRef - return SubscribableInternal.make({ - get: Effect.flatMap(errorRef.get, filter), - get changes() { return Stream.flatMap(errorRef.changes, filter) }, - }) - }, [this.latestValueRef, ...path]) - } - - useInput

>( - options: Form.useInput.Options - ) { - const self = this - return Effect.gen(function*() { - const internalValueRef = yield* Hooks.useMemo(() => self.latestValueRef.pipe( - Effect.andThen(flow( - Schema.encode(self.schema), - Effect.andThen(PropertyPath.get(options.path)), - Effect.catchTag("ParseError", e => options.defaultValue !== undefined && options.defaultValue !== null - ? Effect.succeed(options.defaultValue) - : Effect.fail(e) - ), - )), - Effect.andThen(SubscriptionRef.make>), - ), [self.latestValueRef, ...options.path]) - - const issuesSubscribable = self.useFieldIssuesSubscribable(options.path) - - const [value, setValue] = yield* Hooks.useRefState(internalValueRef) - const [issues] = yield* Hooks.useSubscribe(issuesSubscribable) - - yield* Hooks.useFork(() => Stream.runForEach( - internalValueRef.changes.pipe( - Stream.changesWith(Equivalence.strict()), - // options.debounce ? Stream.debounce(options.debounce) : identity, - Stream.drop(1), - ), - internalValue => self.latestValueRef.pipe( - Effect.andThen(Schema.encode(self.schema)), - Effect.andThen(PropertyPath.immutableSet(options.path, internalValue)), - Effect.andThen(flow( - Schema.decode(self.schema), - Effect.andThen(v => SubscriptionRef.set(self.latestValueRef, v)), - Effect.andThen(SubscriptionRef.set(self.errorRef, Option.none())), - Effect.catchTag("ParseError", e => SubscriptionRef.set(self.errorRef, Option.some(e))) - )), - ), - ), [internalValueRef, self.latestValueRef, self.schema, self.errorRef, ...options.path]) - - return { value, setValue, issues } + const errorRef = this.errorRef + return SubscribableInternal.make({ + get: Effect.flatMap(errorRef.get, filter), + get changes() { return Stream.flatMap(errorRef.changes, filter) }, }) } } -export const make: { - (options: make.Options): Effect.Effect> -} = Effect.fnUntraced(function* (options: make.Options) { - return new FormImpl( - options.schema, - yield* SubscriptionRef.make(options.initialValue), - yield* SubscriptionRef.make(Option.none()), - ) -}) - export namespace make { export interface Options { readonly schema: Schema.Schema readonly initialEncodedValue: NoInfer } } + +export const make: { + (options: make.Options): Effect.Effect> +} = Effect.fnUntraced(function* (options: make.Options) { + return new FormImpl( + options.schema, + yield* SubscriptionRef.make(Option.none()), + yield* SubscriptionRef.make(options.initialEncodedValue), + yield* SubscriptionRef.make(Option.none()), + ) +}) + + +export namespace useInput { + export interface Options> { + readonly path: P + readonly debounce?: Duration.DurationInput + } + + export interface Result { + readonly value: T + readonly setValue: React.Dispatch> + readonly issues: readonly ParseResult.ArrayFormatterIssue[] + } +} + +export const useInput: { + >( + self: Form, + options: useInput.Options, P>, + ): Effect.Effect>, NoSuchElementException, R> +} = Effect.fnUntraced(function* >( + self: Form, + options: useInput.Options, P>, +) { + const [internalValueRef, issuesSubscribable] = yield* Hooks.useMemo(() => Effect.all([ + self.encodedValueRef.pipe( + Effect.andThen(PropertyPath.get(options.path)), + Effect.andThen(SubscriptionRef.make>), + ), + Effect.succeed(self.makeFieldIssuesSubscribable(options.path)), + ]), [self, ...options.path]) + + const [value, setValue] = yield* Hooks.useRefState(internalValueRef) + const [issues] = yield* Hooks.useSubscribe(issuesSubscribable) + + yield* Hooks.useFork(() => Stream.runForEach( + internalValueRef.changes.pipe( + Stream.changesWith(Equivalence.strict()), + options.debounce ? Stream.debounce(options.debounce) : identity, + Stream.drop(1), + ), + + internalValue => self.encodedValueRef.pipe( + Effect.andThen(encodedValue => PropertyPath.immutableSet(encodedValue, options.path, internalValue)), + Effect.tap(encodedValue => SubscriptionRef.set(self.encodedValueRef, encodedValue)), + Effect.andThen(flow( + Schema.decode(self.schema), + Effect.andThen(v => SubscriptionRef.set(self.valueRef, Option.some(v))), + Effect.andThen(SubscriptionRef.set(self.errorRef, Option.none())), + Effect.catchTag("ParseError", e => SubscriptionRef.set(self.errorRef, Option.some(e))) + )), + ), + ), [internalValueRef, self, ...options.path]) + + return { value, setValue, issues } +}) diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index a25a09d..f4bc4e0 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -14,16 +14,16 @@ const RegisterFormSchema = Schema.Struct({ class RegisterForm extends Effect.Service()("RegisterForm", { scoped: Form.make({ schema: RegisterFormSchema, - initialValue: { email: "", password: "" }, + initialEncodedValue: { email: "", password: "" }, }) }) {} class RegisterPage extends Component.makeUntraced(function* RegisterPage() { const form = yield* RegisterForm - const emailInput = yield* form.useInput({ path: ["email"], defaultValue: "" }) - const passwordInput = yield* form.useInput({ path: ["password"], defaultValue: "" }) + const emailInput = yield* Form.useInput(form, { path: ["email"] }) + const passwordInput = yield* Form.useInput(form, { path: ["password"] }) - yield* useFork(() => Stream.runForEach(form.latestValueSubscribable.changes, Console.log), []) + yield* useFork(() => Stream.runForEach(form.valueRef.changes, Console.log), []) return (