diff --git a/packages/effect-fc/src/hooks/Hooks/index.ts b/packages/effect-fc/src/hooks/Hooks/index.ts index 5d0e295..6b3c65d 100644 --- a/packages/effect-fc/src/hooks/Hooks/index.ts +++ b/packages/effect-fc/src/hooks/Hooks/index.ts @@ -3,6 +3,7 @@ export * from "./useCallbackPromise.js" export * from "./useCallbackSync.js" export * from "./useContext.js" export * from "./useEffect.js" +export * from "./useInput.js" export * from "./useLayoutEffect.js" export * from "./useMemo.js" export * from "./useOnce.js" diff --git a/packages/effect-fc/src/hooks/Hooks/useInput.ts b/packages/effect-fc/src/hooks/Hooks/useInput.ts new file mode 100644 index 0000000..4d32d7b --- /dev/null +++ b/packages/effect-fc/src/hooks/Hooks/useInput.ts @@ -0,0 +1,59 @@ +import { type Duration, Effect, flow, Option, ParseResult, Ref, Schema, Stream, SubscriptionRef, Types } 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 useInput { + export interface Options { + readonly ref: SubscriptionRef.SubscriptionRef + readonly schema: Schema.Schema, string, R> + readonly debounce?: Duration.Duration + } + + export interface Result { + readonly value: string + readonly onChange: React.ChangeEventHandler + readonly error: Option.Option + } +} + +export const useInput: { + (options: useInput.Options): Effect.Effect +} = Effect.fnUntraced(function* (options: useInput.Options) { + const internalRef = yield* useOnce(() => options.ref.pipe( + Effect.andThen(Schema.encode(options.schema)), + Effect.andThen(SubscriptionRef.make), + )) + const [error, setError] = React.useState(Option.none()) + + yield* useFork(() => Effect.all([ + Stream.runForEach(options.ref, upstreamValue => + Effect.andThen(internalRef, internalValue => + upstreamValue !== internalValue + ? Effect.andThen(Schema.encode(options.schema)(upstreamValue), v => Ref.set(internalRef, v)) + : Effect.void + ) + ), + + Stream.runForEach( + options.debounce ? Stream.debounce(internalRef, options.debounce) : internalRef, + 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 } +}) diff --git a/packages/example/src/todo/Todo.tsx b/packages/example/src/todo/Todo.tsx index 44c0b48..1cf0aed 100644 --- a/packages/example/src/todo/Todo.tsx +++ b/packages/example/src/todo/Todo.tsx @@ -1,7 +1,7 @@ import * as Domain from "@/domain" -import { Box, Button, Flex, IconButton, TextArea } from "@radix-ui/themes" +import { Box, Button, Callout, Flex, IconButton, Text, TextArea } from "@radix-ui/themes" import { GetRandomValues, makeUuid4 } from "@typed/id" -import { Chunk, Effect, Match, Option, Ref, Runtime, SubscriptionRef } from "effect" +import { Chunk, Effect, Match, Option, ParseResult, Ref, Runtime, Schema, SubscriptionRef } from "effect" import { Component, Memoized } from "effect-fc" import { Hooks } from "effect-fc/hooks" import { SubscriptionSubRef } from "effect-fc/types" @@ -40,15 +40,26 @@ export class Todo extends Component.make(function* Todo(props: TodoProps) { ] as const), ), [props._tag, props.index]) - const [content, size] = yield* Hooks.useSubscribeRefs(contentRef, state.sizeRef) + const [size] = yield* Hooks.useSubscribeRefs(state.sizeRef) + const contentInput = yield* Hooks.useInput({ ref: contentRef, schema: Schema.Any }) return ( + {Option.isSome(contentInput.error) && + + + {ParseResult.ArrayFormatter.formatErrorSync(contentInput.error.value).map(e => <> + • {e.message}
+ )} +
+
+ } +