diff --git a/packages/effect-fc/src/hooks/Hooks/useOptionalInput.ts b/packages/effect-fc/src/hooks/Hooks/useOptionalInput.ts new file mode 100644 index 0000000..3a775ba --- /dev/null +++ b/packages/effect-fc/src/hooks/Hooks/useOptionalInput.ts @@ -0,0 +1,80 @@ +import { type Duration, Effect, flow, identity, Option, type ParseResult, Ref, Schema, Stream, SubscriptionRef } from "effect" +import * as React from "react" +import { useCallbackSync } from "./useCallbackSync.js" +import { useFork } from "./useFork.js" +import { useOnce } from "./useOnce.js" +import { useSubscribeRefs } from "./useSubscribeRefs.js" + + +export namespace useOptionalInput { + export interface Options { + readonly schema: Schema.Schema + readonly defaultValue?: A + readonly ref: SubscriptionRef.SubscriptionRef> + readonly debounce?: Duration.DurationInput + } + + export interface Result { + readonly value: string + readonly onChange: React.ChangeEventHandler + readonly error: Option.Option + } +} + +export const useOptionalInput: { + (options: useOptionalInput.Options): Effect.Effect +} = Effect.fnUntraced(function* (options: useOptionalInput.Options) { + const [internalRef, initialDisabled] = yield* useOnce(() => Effect.andThen(options.ref, upstreamValue => + Effect.all([ + Effect.andThen( + Option.match(upstreamValue, { + onSome: Schema.encode(options.schema), + onNone: () => options.defaultValue + ? Schema.encode(options.schema)(options.defaultValue) + : Effect.succeed(""), + }), + SubscriptionRef.make, + ), + + Effect.succeed(Option.isNone(upstreamValue)), + ]) + )) + + const [disabled, setDisabled] = React.useState(initialDisabled) + const [error, setError] = React.useState(Option.none()) + + yield* useFork(() => Effect.all([ + // Sync the upstream state with the internal state + // Only mutate the internal state if the upstream encoded value is actually different. This avoids infinite re-render loops. + Stream.runForEach(options.ref.changes, Option.match({ + onSome: upstreamValue => Effect.andThen( + Effect.all([Schema.encode(options.schema)(upstreamValue), internalRef]), + ([encodedUpstreamValue, internalValue]) => Effect.when( + Ref.set(internalRef, encodedUpstreamValue), + () => encodedUpstreamValue !== internalValue, + ), + ), + + onNone: () => Effect.sync(() => setDisabled(true)), + })), + + // Sync all changes to the internal state with upstream + Stream.runForEach( + internalRef.changes.pipe(options.debounce ? Stream.debounce(options.debounce) : identity), + flow( + Schema.decode(options.schema), + Effect.andThen(v => Ref.set(options.ref, v)), + Effect.andThen(() => setError(Option.none())), + Effect.catchTag("ParseError", e => Effect.sync(() => setError(Option.some(e)))), + ), + ), + ], { concurrency: "unbounded" }), [options.ref, options.schema, options.debounce, internalRef]) + + const [value] = yield* useSubscribeRefs(internalRef) + const onChange = yield* useCallbackSync((e: React.ChangeEvent) => Ref.set( + internalRef, + e.target.value, + ), [internalRef]) + + return { value, onChange, error } +})