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}
+ >)}
+
+
+ }
+