useInput
All checks were successful
Lint / lint (push) Successful in 12s

This commit is contained in:
Julien Valverdé
2025-07-29 02:57:18 +02:00
parent ec8f9f2ddb
commit b2b002852c
3 changed files with 76 additions and 5 deletions

View File

@@ -3,6 +3,7 @@ export * from "./useCallbackPromise.js"
export * from "./useCallbackSync.js" export * from "./useCallbackSync.js"
export * from "./useContext.js" export * from "./useContext.js"
export * from "./useEffect.js" export * from "./useEffect.js"
export * from "./useInput.js"
export * from "./useLayoutEffect.js" export * from "./useLayoutEffect.js"
export * from "./useMemo.js" export * from "./useMemo.js"
export * from "./useOnce.js" export * from "./useOnce.js"

View File

@@ -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<A, R> {
readonly ref: SubscriptionRef.SubscriptionRef<A>
readonly schema: Schema.Schema<Types.NoInfer<A>, string, R>
readonly debounce?: Duration.Duration
}
export interface Result {
readonly value: string
readonly onChange: React.ChangeEventHandler<HTMLInputElement | HTMLTextAreaElement>
readonly error: Option.Option<ParseResult.ParseError>
}
}
export const useInput: {
<A, R>(options: useInput.Options<A, R>): Effect.Effect<useInput.Result, ParseResult.ParseError, R>
} = Effect.fnUntraced(function* <A, R>(options: useInput.Options<A, R>) {
const internalRef = yield* useOnce(() => options.ref.pipe(
Effect.andThen(Schema.encode(options.schema)),
Effect.andThen(SubscriptionRef.make),
))
const [error, setError] = React.useState(Option.none<ParseResult.ParseError>())
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<HTMLInputElement>) => Ref.set(
internalRef,
e.target.value,
), [internalRef])
return { value, onChange, error }
})

View File

@@ -1,7 +1,7 @@
import * as Domain from "@/domain" 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 { 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 { Component, Memoized } from "effect-fc"
import { Hooks } from "effect-fc/hooks" import { Hooks } from "effect-fc/hooks"
import { SubscriptionSubRef } from "effect-fc/types" import { SubscriptionSubRef } from "effect-fc/types"
@@ -40,15 +40,26 @@ export class Todo extends Component.make(function* Todo(props: TodoProps) {
] as const), ] as const),
), [props._tag, props.index]) ), [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 ( return (
<Flex direction="column" align="stretch" gap="2"> <Flex direction="column" align="stretch" gap="2">
{Option.isSome(contentInput.error) &&
<Callout.Root color="red">
<Callout.Text>
{ParseResult.ArrayFormatter.formatErrorSync(contentInput.error.value).map(e => <>
<Text>&bull; {e.message}</Text><br />
</>)}
</Callout.Text>
</Callout.Root>
}
<Flex direction="row" align="center" gap="2"> <Flex direction="row" align="center" gap="2">
<Box flexGrow="1"> <Box flexGrow="1">
<TextArea <TextArea
value={content} value={contentInput.value}
onChange={e => Runtime.runSync(runtime)(Ref.set(contentRef, e.target.value))} onChange={contentInput.onChange}
/> />
</Box> </Box>