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