0.1.4 (#5)
All checks were successful
Publish / publish (push) Successful in 21s
Lint / lint (push) Successful in 12s

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #5
This commit was merged in pull request #5.
This commit is contained in:
2025-10-02 18:18:23 +02:00
parent 831a808568
commit 9a3c91b50b
66 changed files with 1157 additions and 634 deletions

View File

@@ -16,14 +16,14 @@ Documentation is currently being written. In the meantime, you can take a look a
## What writing components looks like
```typescript
import { Component } from "effect-fc"
import { useOnce, useSubscribe } from "effect-fc/hooks"
import { useOnce, useSubscribables } from "effect-fc/Hooks"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
export class Todos extends Component.makeUntraced(function* Todos() {
export class Todos extends Component.makeUntraced("Todos")(function*() {
const state = yield* TodosState
const [todos] = yield* useSubscribe(state.ref)
const [todos] = yield* useSubscribables(state.ref)
yield* useOnce(() => Effect.andThen(
Console.log("Todos mounted"),
@@ -49,7 +49,7 @@ export class Todos extends Component.makeUntraced(function* Todos() {
const TodosStateLive = TodosState.Default("todos")
const Index = Component.makeUntraced(function* Index() {
const Index = Component.makeUntraced("Index")(function*() {
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Todos, context)

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**"]
}
}

View File

@@ -1,7 +1,7 @@
{
"name": "effect-fc",
"description": "Write React function components with Effect",
"version": "0.1.3",
"version": "0.1.4",
"type": "module",
"files": [
"./README.md",
@@ -17,33 +17,32 @@
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
},
"./hooks": {
"types": "./dist/hooks/index.d.ts",
"default": "./dist/hooks/index.js"
},
"./types": {
"types": "./dist/types/index.d.ts",
"default": "./dist/types/index.js"
},
"./*": {
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
"./*": [
{
"types": "./dist/*/index.d.ts",
"default": "./dist/*/index.js"
},
{
"types": "./dist/*.d.ts",
"default": "./dist/*.js"
}
]
},
"scripts": {
"build": "tsc",
"lint:tsc": "tsc --noEmit",
"lint:biome": "biome lint",
"pack": "npm pack",
"clean:cache": "rm -f tsconfig.tsbuildinfo",
"clean:cache": "rm -rf .turbo tsconfig.tsbuildinfo",
"clean:dist": "rm -rf dist",
"clean:node": "rm -rf node_modules"
"clean:modules": "rm -rf node_modules"
},
"peerDependencies": {
"@types/react": "^19.0.0",
"effect": "^3.15.0",
"react": "^19.0.0"
},
"devDependencies": {
"@effect/language-service": "^0.35.2"
"dependencies": {
"@typed/async-data": "^0.13.1"
}
}

View File

@@ -1,16 +1,17 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
import * as React from "react"
import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("effect-fc/Suspense")
export const TypeId: unique symbol = Symbol.for("effect-fc/Async")
export type TypeId = typeof TypeId
export interface Suspense extends Suspense.Options {
export interface Async extends Async.Options {
readonly [TypeId]: TypeId
}
export namespace Suspense {
export namespace Async {
export interface Options {
readonly defaultFallback?: React.ReactNode
}
@@ -23,13 +24,13 @@ const SuspenseProto = Object.freeze({
[TypeId]: TypeId,
makeFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
this: Component.Component<P, A, E, R> & Suspense,
this: Component.Component<P, A, E, R> & Async,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
scope: Scope.Scope,
) {
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
return ({ fallback, name, ...props }: Suspense.Props) => {
return ({ fallback, name, ...props }: Async.Props) => {
const promise = Runtime.runPromise(runtimeRef.current)(
Effect.provideService(this.body(props as P), Scope.Scope, scope)
)
@@ -44,19 +45,19 @@ const SuspenseProto = Object.freeze({
} as const)
export const isSuspense = (u: unknown): u is Suspense => Predicate.hasProperty(u, TypeId)
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
export const suspense = <T extends Component.Component<any, any, any, any>>(
export const async = <T extends Component.Component<any, any, any, any>>(
self: T
): (
& Omit<T, keyof Component.Component.AsComponent<T>>
& Component.Component<
Component.Component.Props<T> & Suspense.Props,
Component.Component.Props<T> & Async.Props,
Component.Component.Success<T>,
Component.Component.Error<T>,
Component.Component.Context<T>
>
& Suspense
& Async
) => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf(
@@ -66,16 +67,16 @@ export const suspense = <T extends Component.Component<any, any, any, any>>(
)
export const withOptions: {
<T extends Component.Component<any, any, any, any> & Suspense>(
options: Partial<Suspense.Options>
<T extends Component.Component<any, any, any, any> & Async>(
options: Partial<Async.Options>
): (self: T) => T
<T extends Component.Component<any, any, any, any> & Suspense>(
<T extends Component.Component<any, any, any, any> & Async>(
self: T,
options: Partial<Suspense.Options>,
options: Partial<Async.Options>,
): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Suspense>(
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Async>(
self: T,
options: Partial<Suspense.Options>,
options: Partial<Async.Options>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),

View File

@@ -1,7 +1,9 @@
import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, String, Tracer, type Types, type Utils } from "effect"
/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Context, Effect, Effectable, ExecutionStrategy, Function, Predicate, Runtime, Scope, Tracer, type Types, type Utils } from "effect"
import * as React from "react"
import { Hooks } from "./hooks/index.js"
import * as Memo from "./Memo.js"
import * as Hooks from "./Hooks/index.js"
import { Memoized } from "./index.js"
export const TypeId: unique symbol = Symbol.for("effect-fc/Component")
@@ -12,12 +14,12 @@ extends
Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>,
Component.Options
{
new(_: never): {}
new(_: never): Record<string, never>
readonly [TypeId]: TypeId
readonly ["~Props"]: P
readonly ["~Success"]: A
readonly ["~Error"]: E
readonly ["~Context"]: R
readonly "~Props": P
readonly "~Success": A
readonly "~Error": E
readonly "~Context": R
/** @internal */
readonly body: (props: P) => Effect.Effect<A, E, R>
@@ -53,6 +55,7 @@ const ComponentProto = Object.freeze({
this: Component<P, A, E, R>
) {
const self = this
// biome-ignore lint/style/noNonNullAssertion: context initialization
const runtimeRef = React.useRef<Runtime.Runtime<Exclude<R, Scope.Scope>>>(null!)
runtimeRef.current = yield* Effect.runtime<Exclude<R, Scope.Scope>>()
@@ -67,7 +70,7 @@ const ComponentProto = Object.freeze({
const FC = React.useMemo(() => {
const f: React.FC<P> = self.makeFunctionComponent(runtimeRef, scope)
f.displayName = self.displayName ?? "Anonymous"
return Memo.isMemo(self)
return Memoized.isMemoized(self)
? React.memo(f, self.propsAreEqual)
: f
}, [scope])
@@ -331,13 +334,9 @@ export const make: (
) => make.Gen & make.NonGen)
) = (spanNameOrBody: Function | string, ...pipeables: any[]): any => {
if (typeof spanNameOrBody !== "string") {
const displayName = displayNameFromBody(spanNameOrBody)
return Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: displayName
? Effect.fn(displayName)(spanNameOrBody as any, ...pipeables as [])
: Effect.fn(spanNameOrBody as any, ...pipeables),
displayName,
body: Effect.fn(spanNameOrBody as any, ...pipeables),
}),
ComponentProto,
)
@@ -347,26 +346,34 @@ export const make: (
return (body: any, ...pipeables: any[]) => Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: Effect.fn(spanNameOrBody, spanOptions)(body, ...pipeables as []),
displayName: displayNameFromBody(body) ?? spanNameOrBody,
displayName: spanNameOrBody,
}),
ComponentProto,
)
}
}
export const makeUntraced: make.Gen & make.NonGen = (
body: Function,
...pipeables: any[]
) => Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: Effect.fnUntraced(body as any, ...pipeables as []),
displayName: displayNameFromBody(body),
}),
ComponentProto,
export const makeUntraced: (
& make.Gen
& make.NonGen
& ((name: string) => make.Gen & make.NonGen)
) = (spanNameOrBody: Function | string, ...pipeables: any[]): any => (
typeof spanNameOrBody !== "string"
? Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: Effect.fnUntraced(spanNameOrBody as any, ...pipeables as []),
}),
ComponentProto,
)
: (body: any, ...pipeables: any[]) => Object.setPrototypeOf(
Object.assign(function() {}, defaultOptions, {
body: Effect.fnUntraced(body, ...pipeables as []),
displayName: spanNameOrBody,
}),
ComponentProto,
)
)
const displayNameFromBody = (body: Function) => !String.isEmpty(body.name) ? body.name : undefined
export const withOptions: {
<T extends Component<any, any, any, any>>(
options: Partial<Component.Options>

View File

@@ -0,0 +1,411 @@
import * as AsyncData from "@typed/async-data"
import { Array, Cause, Chunk, type Duration, Effect, Equal, Exit, Fiber, flow, identity, Option, ParseResult, Pipeable, Predicate, pipe, Ref, Schema, type Scope, Stream, type Subscribable, SubscriptionRef } from "effect"
import type { NoSuchElementException } from "effect/Cause"
import * as React from "react"
import * as Hooks from "./Hooks/index.js"
import * as PropertyPath from "./PropertyPath.js"
import * as SubscribableInternal from "./Subscribable.js"
import * as SubscriptionSubRef from "./SubscriptionSubRef.js"
export const FormTypeId: unique symbol = Symbol.for("effect-fc/Form")
export type FormTypeId = typeof FormTypeId
export interface Form<in out A, in out I = A, out R = never, in out SA = void, in out SE = A, out SR = never>
extends Pipeable.Pipeable {
readonly [FormTypeId]: FormTypeId
readonly schema: Schema.Schema<A, I, R>
readonly submit: (value: NoInfer<A>) => Effect.Effect<SA, SE, SR>
readonly debounce: Option.Option<Duration.DurationInput>
readonly valueRef: SubscriptionRef.SubscriptionRef<Option.Option<A>>
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>
readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>
readonly validationFiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<void, never>>>
readonly submitStateRef: SubscriptionRef.SubscriptionRef<AsyncData.AsyncData<SA, SE>>
readonly canSubmitSubscribable: Subscribable.Subscribable<boolean>
}
class FormImpl<in out A, in out I = A, out R = never, in out SA = void, in out SE = A, out SR = never>
extends Pipeable.Class() implements Form<A, I, R, SA, SE, SR> {
readonly [FormTypeId]: FormTypeId = FormTypeId
constructor(
readonly schema: Schema.Schema<A, I, R>,
readonly submit: (value: NoInfer<A>) => Effect.Effect<SA, SE, SR>,
readonly debounce: Option.Option<Duration.DurationInput>,
readonly valueRef: SubscriptionRef.SubscriptionRef<Option.Option<A>>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly errorRef: SubscriptionRef.SubscriptionRef<Option.Option<ParseResult.ParseError>>,
readonly validationFiberRef: SubscriptionRef.SubscriptionRef<Option.Option<Fiber.Fiber<void, never>>>,
readonly submitStateRef: SubscriptionRef.SubscriptionRef<AsyncData.AsyncData<SA, SE>>,
readonly canSubmitSubscribable: Subscribable.Subscribable<boolean>,
) {
super()
}
}
export const isForm = (u: unknown): u is Form<unknown, unknown, unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, FormTypeId)
export namespace make {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never> {
readonly schema: Schema.Schema<A, I, R>
readonly initialEncodedValue: NoInfer<I>
readonly submit: (value: NoInfer<A>) => Effect.Effect<SA, SE, SR>,
readonly debounce?: Duration.DurationInput,
}
}
export const make: {
<A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>
): Effect.Effect<Form<A, I, R, SA, SE, SR>>
} = Effect.fnUntraced(function* <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>
) {
const valueRef = yield* SubscriptionRef.make(Option.none<A>())
const errorRef = yield* SubscriptionRef.make(Option.none<ParseResult.ParseError>())
const validationFiberRef = yield* SubscriptionRef.make(Option.none<Fiber.Fiber<void, never>>())
const submitStateRef = yield* SubscriptionRef.make(AsyncData.noData<SA, SE>())
return new FormImpl(
options.schema,
options.submit,
Option.fromNullable(options.debounce),
valueRef,
yield* SubscriptionRef.make(options.initialEncodedValue),
errorRef,
validationFiberRef,
submitStateRef,
pipe(
<A>([value, error, validationFiber, submitState]: readonly [
Option.Option<A>,
Option.Option<ParseResult.ParseError>,
Option.Option<Fiber.Fiber<void, never>>,
AsyncData.AsyncData<SA, SE>,
]) => Option.isSome(value) && Option.isNone(error) && Option.isNone(validationFiber) && !AsyncData.isLoading(submitState),
filter => SubscribableInternal.make({
get: Effect.map(Effect.all([valueRef, errorRef, validationFiberRef, submitStateRef]), filter),
get changes() {
return Stream.map(
Stream.zipLatestAll(
valueRef.changes,
errorRef.changes,
validationFiberRef.changes,
submitStateRef.changes,
),
filter,
)
},
}),
),
)
})
export const run = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<void, never, Scope.Scope | R> => Stream.runForEach(
self.encodedValueRef.changes.pipe(
Option.isSome(self.debounce) ? Stream.debounce(self.debounce.value) : identity
),
encodedValue => self.validationFiberRef.pipe(
Effect.andThen(Option.match({
onSome: Fiber.interrupt,
onNone: () => Effect.void,
})),
Effect.andThen(
Effect.addFinalizer(() => SubscriptionRef.set(self.validationFiberRef, Option.none())).pipe(
Effect.andThen(Schema.decode(self.schema, { errors: "all" })(encodedValue)),
Effect.exit,
Effect.andThen(flow(
Exit.matchEffect({
onSuccess: v => Effect.andThen(
SubscriptionRef.set(self.valueRef, Option.some(v)),
SubscriptionRef.set(self.errorRef, Option.none()),
),
onFailure: c => Option.match(
Chunk.findFirst(Cause.failures(c), e => e._tag === "ParseError"),
{
onSome: e => SubscriptionRef.set(self.errorRef, Option.some(e)),
onNone: () => Effect.void,
},
),
}),
Effect.uninterruptible,
)),
Effect.scoped,
Effect.forkScoped,
)
),
Effect.andThen(fiber => SubscriptionRef.set(self.validationFiberRef, Option.some(fiber)))
),
)
export const submit = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<Option.Option<AsyncData.AsyncData<SA, SE>>, NoSuchElementException, SR> => Effect.whenEffect(
self.valueRef.pipe(
Effect.andThen(identity),
Effect.tap(Ref.set(self.submitStateRef, AsyncData.loading())),
Effect.andThen(flow(
self.submit,
Effect.exit,
Effect.map(Exit.match({
onSuccess: a => AsyncData.success(a),
onFailure: e => AsyncData.failure(e),
})),
Effect.tap(v => Ref.set(self.submitStateRef, v))
)),
),
self.canSubmitSubscribable.get,
)
export namespace service {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never>
extends make.Options<A, I, R, SA, SE, SR> {}
}
export const service = <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: service.Options<A, I, R, SA, SE, SR>
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, R | Scope.Scope> => Effect.tap(
make(options),
form => Effect.forkScoped(run(form)),
)
export const field = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, SA, SE, SR>,
path: P,
): FormField<PropertyPath.ValueFromPath<A, P>, PropertyPath.ValueFromPath<I, P>> => new FormFieldImpl(
pipe(
Option.match({
onSome: (v: A) => Option.map(PropertyPath.get(v, path), Option.some),
onNone: () => Option.some(Option.none()),
}),
filter => SubscribableInternal.make({
get: Effect.flatMap(self.valueRef, filter),
get changes() { return Stream.flatMap(self.valueRef.changes, filter) },
}),
),
SubscriptionSubRef.makeFromPath(self.encodedValueRef, path),
pipe(
Option.match({
onSome: (v: ParseResult.ParseError) => Effect.andThen(
ParseResult.ArrayFormatter.formatError(v),
Array.filter(issue => PropertyPath.equivalence(issue.path, path)),
),
onNone: () => Effect.succeed([]),
}),
filter => SubscribableInternal.make({
get: Effect.flatMap(self.errorRef.get, filter),
get changes() { return Stream.flatMap(self.errorRef.changes, filter) },
}),
),
pipe(
Option.isSome,
filter => SubscribableInternal.make({
get: Effect.map(self.validationFiberRef.get, filter),
get changes() { return Stream.map(self.validationFiberRef.changes, filter) },
}),
),
pipe(
AsyncData.isLoading,
filter => SubscribableInternal.make({
get: Effect.map(self.submitStateRef, filter),
get changes() { return Stream.map(self.submitStateRef.changes, filter) },
}),
),
)
export const FormFieldTypeId: unique symbol = Symbol.for("effect-fc/FormField")
export type FormFieldTypeId = typeof FormFieldTypeId
export interface FormField<in out A, in out I = A>
extends Pipeable.Pipeable {
readonly [FormFieldTypeId]: FormFieldTypeId
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>
}
class FormFieldImpl<in out A, in out I = A>
extends Pipeable.Class() implements FormField<A, I> {
readonly [FormFieldTypeId]: FormFieldTypeId = FormFieldTypeId
constructor(
readonly valueSubscribable: Subscribable.Subscribable<Option.Option<A>, NoSuchElementException>,
readonly encodedValueRef: SubscriptionRef.SubscriptionRef<I>,
readonly issuesSubscribable: Subscribable.Subscribable<readonly ParseResult.ArrayFormatterIssue[]>,
readonly isValidatingSubscribable: Subscribable.Subscribable<boolean>,
readonly isSubmittingSubscribable: Subscribable.Subscribable<boolean>,
) {
super()
}
}
export const isFormField = (u: unknown): u is FormField<unknown, unknown> => Predicate.hasProperty(u, FormFieldTypeId)
export namespace useForm {
export interface Options<in out A, in out I, out R, in out SA = void, in out SE = A, out SR = never>
extends make.Options<A, I, R, SA, SE, SR> {}
}
export const useForm: {
<A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>,
deps: React.DependencyList,
): Effect.Effect<Form<A, I, R, SA, SE, SR>, never, R>
} = Effect.fnUntraced(function* <A, I = A, R = never, SA = void, SE = A, SR = never>(
options: make.Options<A, I, R, SA, SE, SR>,
deps: React.DependencyList,
) {
const form = yield* Hooks.useMemo(() => make(options), [options.debounce, ...deps])
yield* Hooks.useFork(() => run(form), [form])
return form
})
export const useSubmit = <A, I, R, SA, SE, SR>(
self: Form<A, I, R, SA, SE, SR>
): Effect.Effect<
() => Promise<Option.Option<AsyncData.AsyncData<SA, SE>>>,
never,
SR
> => Hooks.useCallbackPromise(() => submit(self), [self])
export const useField = <A, I, R, SA, SE, SR, const P extends PropertyPath.Paths<NoInfer<I>>>(
self: Form<A, I, R, SA, SE, SR>,
path: P,
): FormField<
PropertyPath.ValueFromPath<A, P>,
PropertyPath.ValueFromPath<I, P>
// biome-ignore lint/correctness/useExhaustiveDependencies: individual path components need to be compared
> => React.useMemo(() => field(self, path), [self, ...path])
export namespace useInput {
export interface Options {
readonly debounce?: Duration.DurationInput
}
export interface Result<T> {
readonly value: T
readonly setValue: React.Dispatch<React.SetStateAction<T>>
}
}
export const useInput: {
<A, I>(
field: FormField<A, I>,
options?: useInput.Options,
): Effect.Effect<useInput.Result<I>, NoSuchElementException>
} = Effect.fnUntraced(function* <A, I>(
field: FormField<A, I>,
options?: useInput.Options,
) {
const internalValueRef = yield* Hooks.useMemo(() => Effect.andThen(field.encodedValueRef, SubscriptionRef.make), [field])
const [value, setValue] = yield* Hooks.useRefState(internalValueRef)
yield* Hooks.useFork(() => Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect(
Ref.set(internalValueRef, upstreamEncodedValue),
Effect.andThen(internalValueRef, internalValue => !Equal.equals(upstreamEncodedValue, internalValue)),
),
),
Stream.runForEach(
internalValueRef.changes.pipe(
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
internalValue => Ref.set(field.encodedValueRef, internalValue),
),
], { concurrency: "unbounded" }), [field, internalValueRef, options?.debounce])
return { value, setValue }
})
export namespace useOptionalInput {
export interface Options<T> extends useInput.Options {
readonly defaultValue: T
}
export interface Result<T> extends useInput.Result<T> {
readonly enabled: boolean
readonly setEnabled: React.Dispatch<React.SetStateAction<boolean>>
}
}
export const useOptionalInput: {
<A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>,
): Effect.Effect<useOptionalInput.Result<I>, NoSuchElementException>
} = Effect.fnUntraced(function* <A, I>(
field: FormField<A, Option.Option<I>>,
options: useOptionalInput.Options<I>,
) {
const [enabledRef, internalValueRef] = yield* Hooks.useMemo(() => Effect.andThen(
field.encodedValueRef,
Option.match({
onSome: v => Effect.all([SubscriptionRef.make(true), SubscriptionRef.make(v)]),
onNone: () => Effect.all([SubscriptionRef.make(false), SubscriptionRef.make(options.defaultValue)]),
}),
), [field])
const [enabled, setEnabled] = yield* Hooks.useRefState(enabledRef)
const [value, setValue] = yield* Hooks.useRefState(internalValueRef)
yield* Hooks.useFork(() => Effect.all([
Stream.runForEach(
Stream.drop(field.encodedValueRef, 1),
upstreamEncodedValue => Effect.whenEffect(
Option.match(upstreamEncodedValue, {
onSome: v => Effect.andThen(
Ref.set(enabledRef, true),
Ref.set(internalValueRef, v),
),
onNone: () => Effect.andThen(
Ref.set(enabledRef, false),
Ref.set(internalValueRef, options.defaultValue),
),
}),
Effect.andThen(
Effect.all([enabledRef, internalValueRef]),
([enabled, internalValue]) => !Equal.equals(upstreamEncodedValue, enabled ? Option.some(internalValue) : Option.none()),
),
),
),
Stream.runForEach(
enabledRef.changes.pipe(
Stream.zipLatest(internalValueRef.changes),
Stream.drop(1),
Stream.changesWith(Equal.equivalence()),
options?.debounce ? Stream.debounce(options.debounce) : identity,
),
([enabled, internalValue]) => Ref.set(field.encodedValueRef, enabled ? Option.some(internalValue) : Option.none()),
),
], { concurrency: "unbounded" }), [field, enabledRef, internalValueRef, options.debounce])
return { enabled, setEnabled, value, setValue }
})

View File

@@ -12,5 +12,5 @@ export * from "./useRefFromState.js"
export * from "./useRefState.js"
export * from "./useScope.js"
export * from "./useStreamFromReactiveValues.js"
export * from "./useSubscribe.js"
export * from "./useSubscribables.js"
export * from "./useSubscribeStream.js"

View File

@@ -1,11 +1,11 @@
import { type Duration, Effect, Equal, Equivalence, flow, identity, Option, type ParseResult, Ref, Schema, Stream, SubscriptionRef } from "effect"
import * as React from "react"
import { SetStateAction } from "../../../types/index.js"
import * as SetStateAction from "../../SetStateAction.js"
import { useCallbackSync } from "../useCallbackSync.js"
import { useFork } from "../useFork.js"
import { useOnce } from "../useOnce.js"
import { useRefState } from "../useRefState.js"
import { useSubscribe } from "../useSubscribe.js"
import { useSubscribables } from "../useSubscribables.js"
export namespace useOptionalInput {
@@ -101,7 +101,7 @@ export const useOptionalInput: {
[options.schema, options.ref, internalRef, enabledRef],
)
const [enabled] = yield* useSubscribe(enabledRef)
const [enabled] = yield* useSubscribables(enabledRef)
const [value, setValue] = yield* useRefState(internalRef)
return { value, setValue, enabled, setEnabled, error }
})

View File

@@ -11,8 +11,10 @@ export const useCallbackPromise: {
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
// biome-ignore lint/style/noNonNullAssertion: context initialization
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
runtimeRef.current = yield* Effect.runtime<R>()
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
return React.useCallback((...args: Args) => Runtime.runPromise(runtimeRef.current)(callback(...args)), deps)
})

View File

@@ -11,8 +11,10 @@ export const useCallbackSync: {
callback: (...args: Args) => Effect.Effect<A, E, R>,
deps: React.DependencyList,
) {
// biome-ignore lint/style/noNonNullAssertion: context initialization
const runtimeRef = React.useRef<Runtime.Runtime<R>>(null!)
runtimeRef.current = yield* Effect.runtime<R>()
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
return React.useCallback((...args: Args) => Runtime.runSync(runtimeRef.current)(callback(...args)), deps)
})

View File

@@ -1,7 +1,7 @@
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
import * as React from "react"
import type { ScopeOptions } from "./ScopeOptions.js"
import { closeScope } from "./internal.js"
import type { ScopeOptions } from "./ScopeOptions.js"
export const useEffect: {
@@ -24,5 +24,6 @@ export const useEffect: {
() => closeScope(scope, runtime, options)
),
Runtime.runSync(runtime),
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
), deps)
})

View File

@@ -27,5 +27,6 @@ export const useFork: {
...options,
finalizerExecutionMode: options?.finalizerExecutionMode ?? "fork",
})
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
}, deps)
})

View File

@@ -1,7 +1,7 @@
import { Effect, ExecutionStrategy, Runtime, Scope } from "effect"
import * as React from "react"
import type { ScopeOptions } from "./ScopeOptions.js"
import { closeScope } from "./internal.js"
import type { ScopeOptions } from "./ScopeOptions.js"
export const useLayoutEffect: {
@@ -24,5 +24,6 @@ export const useLayoutEffect: {
() => closeScope(scope, runtime, options)
),
Runtime.runSync(runtime),
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
), deps)
})

View File

@@ -12,5 +12,6 @@ export const useMemo: {
deps: React.DependencyList,
) {
const runtime = yield* Effect.runtime()
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
return yield* React.useMemo(() => Runtime.runSync(runtime)(Effect.cached(factory())), deps)
})

View File

@@ -1,6 +1,6 @@
import { Effect, Equivalence, Ref, Stream, SubscriptionRef } from "effect"
import { Effect, Equivalence, Ref, Stream, type SubscriptionRef } from "effect"
import * as React from "react"
import { SetStateAction } from "../../types/index.js"
import * as SetStateAction from "../SetStateAction.js"
import { useCallbackSync } from "./useCallbackSync.js"
import { useFork } from "./useFork.js"
import { useOnce } from "./useOnce.js"

View File

@@ -1,7 +1,7 @@
import { Effect, ExecutionStrategy, Ref, Runtime, Scope } from "effect"
import * as React from "react"
import type { ScopeOptions } from "./ScopeOptions.js"
import { closeScope } from "./internal.js"
import type { ScopeOptions } from "./ScopeOptions.js"
export const useScope: {
@@ -12,6 +12,7 @@ export const useScope: {
} = Effect.fnUntraced(function*(deps, options) {
const runtime = yield* Effect.runtime()
// biome-ignore lint/correctness/useExhaustiveDependencies: no reactivity needed
const [isInitialRun, initialScope] = React.useMemo(() => Runtime.runSync(runtime)(Effect.all([
Ref.make(true),
Scope.make(options?.finalizerExecutionStrategy ?? ExecutionStrategy.sequential),
@@ -30,6 +31,7 @@ export const useScope: {
Effect.map(scope => () => closeScope(scope, runtime, options)),
),
})
// biome-ignore lint/correctness/useExhaustiveDependencies: use of React.DependencyList
), deps)
return scope

View File

@@ -4,7 +4,7 @@ import { useFork } from "./useFork.js"
import { useOnce } from "./useOnce.js"
export const useSubscribe: {
export const useSubscribables: {
<const T extends readonly Subscribable.Subscribable<any, any, any>[]>(
...elements: T
): Effect.Effect<

View File

@@ -16,6 +16,7 @@ export const useSubscribeStream: {
initialValue?: A,
) {
const [reactStateValue, setReactStateValue] = React.useState(
// biome-ignore lint/correctness/useExhaustiveDependencies: no reactivity needed
React.useMemo(() => initialValue
? Option.some(initialValue)
: Option.none(),

View File

@@ -1,50 +0,0 @@
import { type Equivalence, Function, Predicate } from "effect"
import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("effect-fc/Memo")
export type TypeId = typeof TypeId
export interface Memo<P> extends Memo.Options<P> {
readonly [TypeId]: TypeId
}
export namespace Memo {
export interface Options<P> {
readonly propsAreEqual?: Equivalence.Equivalence<P>
}
}
const MemoProto = Object.freeze({
[TypeId]: TypeId
} as const)
export const isMemo = (u: unknown): u is Memo<unknown> => Predicate.hasProperty(u, TypeId)
export const memo = <T extends Component.Component<any, any, any, any>>(
self: T
): T & Memo<Component.Component.Props<T>> => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, MemoProto),
Object.getPrototypeOf(self),
)),
)
export const withOptions: {
<T extends Component.Component<any, any, any, any> & Memo<any>>(
options: Partial<Memo.Options<Component.Component.Props<T>>>
): (self: T) => T
<T extends Component.Component<any, any, any, any> & Memo<any>>(
self: T,
options: Partial<Memo.Options<Component.Component.Props<T>>>,
): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memo<any>>(
self: T,
options: Partial<Memo.Options<Component.Component.Props<T>>>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
))

View File

@@ -0,0 +1,51 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { type Equivalence, Function, Predicate } from "effect"
import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("effect-fc/Memoized")
export type TypeId = typeof TypeId
export interface Memoized<P> extends Memoized.Options<P> {
readonly [TypeId]: TypeId
}
export namespace Memoized {
export interface Options<P> {
readonly propsAreEqual?: Equivalence.Equivalence<P>
}
}
const MemoizedProto = Object.freeze({
[TypeId]: TypeId
} as const)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
export const memoized = <T extends Component.Component<any, any, any, any>>(
self: T
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, MemoizedProto),
Object.getPrototypeOf(self),
)),
)
export const withOptions: {
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
options: Partial<Memoized.Options<Component.Component.Props<T>>>
): (self: T) => T
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
self: T,
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
): T
} = Function.dual(2, <T extends Component.Component<any, any, any, any> & Memoized<any>>(
self: T,
options: Partial<Memoized.Options<Component.Component.Props<T>>>,
): T => Object.setPrototypeOf(
Object.assign(function() {}, self, options),
Object.getPrototypeOf(self),
))

View File

@@ -1,34 +1,34 @@
import { Array, Function, Option, Predicate } from "effect"
import { Array, Equivalence, Function, Option, Predicate } from "effect"
export type PropertyPath = readonly PropertyKey[]
type Prev = readonly [never, 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
export type Paths<T, D extends number = 5, Seen = never> = [] | (
D extends never ? [] :
T extends Seen ? [] :
T extends readonly any[] ? ArrayPaths<T, D, Seen | T> :
T extends object ? ObjectPaths<T, D, Seen | T> :
export type Paths<T, D extends number = 5, Seen = never> = readonly [] | (
D extends never ? readonly [] :
T extends Seen ? readonly [] :
T extends readonly any[] ? {
[K in keyof T as K extends number ? K : never]:
| readonly [K]
| readonly [K, ...Paths<T[K], Prev[D], Seen | T>]
} extends infer O
? O[keyof O]
: never
:
T extends object ? {
[K in keyof T as K extends string | number | symbol ? K : never]-?:
NonNullable<T[K]> extends infer V
? readonly [K] | readonly [K, ...Paths<V, Prev[D], Seen>]
: never
} extends infer O
? O[keyof O]
: never
:
never
)
export type ArrayPaths<T extends readonly any[], D extends number, Seen> = {
[K in keyof T as K extends number ? K : never]:
| [K]
| [K, ...Paths<T[K], Prev[D], Seen>]
} extends infer O
? O[keyof O]
: never
export type ObjectPaths<T extends object, D extends number, Seen> = {
[K in keyof T as K extends string | number | symbol ? K : never]-?:
NonNullable<T[K]> extends infer V
? [K] | [K, ...Paths<V, Prev[D], Seen>]
: never
} extends infer O
? O[keyof O]
: never
export type ValueFromPath<T, P extends any[]> = P extends [infer Head, ...infer Tail]
export type ValueFromPath<T, P extends readonly any[]> = P extends readonly [infer Head, ...infer Tail]
? Head extends keyof T
? ValueFromPath<T[Head], Tail>
: T extends readonly any[]
@@ -38,8 +38,8 @@ export type ValueFromPath<T, P extends any[]> = P extends [infer Head, ...infer
: never
: T
export type AnyPath = readonly PropertyKey[]
export const equivalence: Equivalence.Equivalence<PropertyPath> = Equivalence.array(Equivalence.strict())
export const unsafeGet: {
<T, const P extends Paths<T>>(path: P): (self: T) => ValueFromPath<T, P>
@@ -64,16 +64,16 @@ export const get: {
)
export const immutableSet: {
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => ValueFromPath<T, P>
<T, const P extends Paths<T>>(path: P, value: ValueFromPath<T, P>): (self: T) => Option.Option<T>
<T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T>
} = Function.dual(3, <T, const P extends Paths<T>>(self: T, path: P, value: ValueFromPath<T, P>): Option.Option<T> => {
const key = Array.head(path as AnyPath)
const key = Array.head(path as PropertyPath)
if (Option.isNone(key))
return Option.some(value as T)
if (!Predicate.hasProperty(self, key.value))
return Option.none()
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as AnyPath)), value)
const child = immutableSet<any, any>(self[key.value], Option.getOrThrow(Array.tail(path as PropertyPath)), value)
if (Option.isNone(child))
return child

View File

@@ -1,3 +1,4 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Effect, type Layer, ManagedRuntime, Predicate, type Runtime } from "effect"
import * as React from "react"
@@ -6,7 +7,7 @@ export const TypeId: unique symbol = Symbol.for("effect-fc/ReactRuntime")
export type TypeId = typeof TypeId
export interface ReactRuntime<R, ER> {
new(_: never): {}
new(_: never): Record<string, never>
readonly [TypeId]: TypeId
readonly runtime: ManagedRuntime.ManagedRuntime<R, ER>
readonly context: React.Context<Runtime.Runtime<R>>
@@ -23,6 +24,7 @@ export const make = <R, ER>(
): ReactRuntime<R, ER> => Object.setPrototypeOf(
Object.assign(function() {}, {
runtime: ManagedRuntime.make(layer, memoMap),
// biome-ignore lint/style/noNonNullAssertion: context initialization
context: React.createContext<Runtime.Runtime<R>>(null!),
}),
ReactRuntimeProto,
@@ -48,16 +50,14 @@ export const Provider = <R, ER>(
)
}
namespace ProviderInner {
export interface Props<R, ER> {
readonly runtime: ReactRuntime<R, ER>
readonly promise: Promise<Runtime.Runtime<R>>
readonly children?: React.ReactNode
}
interface ProviderInnerProps<R, ER> {
readonly runtime: ReactRuntime<R, ER>
readonly promise: Promise<Runtime.Runtime<R>>
readonly children?: React.ReactNode
}
const ProviderInner = <R, ER>(
{ runtime, promise, children }: ProviderInner.Props<R, ER>
{ runtime, promise, children }: ProviderInnerProps<R, ER>
): React.ReactNode => React.createElement(
runtime.context,
{ value: React.use(promise) },

View File

@@ -2,7 +2,7 @@ import { Chunk, Effect, Effectable, Option, Predicate, Readable, Ref, Stream, Su
import * as PropertyPath from "./PropertyPath.js"
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("effect-fc/types/SubscriptionSubRef")
export const SubscriptionSubRefTypeId: unique symbol = Symbol.for("effect-fc/SubscriptionSubRef")
export type SubscriptionSubRefTypeId = typeof SubscriptionSubRefTypeId
export interface SubscriptionSubRef<in out A, in out B extends SubscriptionRef.SubscriptionRef<any>>
@@ -78,7 +78,7 @@ extends Effectable.Class<A> implements SubscriptionSubRef<A, B> {
return Effect.Do.pipe(
Effect.bind("b", (): Effect.Effect<Effect.Effect.Success<B>> => this.parent),
Effect.bind("ca", ({ b }) => f(this.getter(b))),
Effect.tap(({ b, ca: [, a] }) => Ref.set(this.parent, this.setter(b, a))),
Effect.tap(({ b, ca: [, a] }) => SubscriptionRef.set(this.parent, this.setter(b, a))),
Effect.map(({ ca: [c] }) => c),
)
}

View File

@@ -1,2 +0,0 @@
export * from "./Hooks/index.js"
export * as Hooks from "./Hooks/index.js"

View File

@@ -1,4 +1,10 @@
export * as Async from "./Async.js"
export * as Component from "./Component.js"
export * as Memo from "./Memo.js"
export * as Form from "./Form.js"
export * as Hooks from "./Hooks/index.js"
export * as Memoized from "./Memoized.js"
export * as PropertyPath from "./PropertyPath.js"
export * as ReactRuntime from "./ReactRuntime.js"
export * as Suspense from "./Suspense.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Subscribable from "./Subscribable.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"

View File

@@ -1,4 +0,0 @@
export * as PropertyPath from "./PropertyPath.js"
export * as SetStateAction from "./SetStateAction.js"
export * as Subscribable from "./Subscribable.js"
export * as SubscriptionSubRef from "./SubscriptionSubRef.js"

View File

@@ -26,7 +26,12 @@
// Build
"outDir": "./dist",
"declaration": true
"declaration": true,
"sourceMap": true,
"plugins": [
{ "name": "@effect/language-service" }
]
},
"include": ["./src"]

View File

@@ -0,0 +1,8 @@
{
"$schema": "https://biomejs.dev/schemas/latest/schema.json",
"root": false,
"extends": "//",
"files": {
"includes": ["./src/**", "!src/routeTree.gen.ts"]
}
}

View File

@@ -1,28 +0,0 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
},
)

View File

@@ -5,43 +5,40 @@
"private": true,
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint:tsc": "tsc --noEmit",
"lint:eslint": "eslint .",
"preview": "vite preview"
"lint:biome": "biome lint",
"preview": "vite preview",
"clean:cache": "rm -rf .turbo node_modules/.tmp node_modules/.vite* .tanstack",
"clean:dist": "rm -rf dist",
"clean:modules": "rm -rf node_modules"
},
"devDependencies": {
"@effect/language-service": "^0.35.2",
"@eslint/js": "^9.34.0",
"@tanstack/react-router": "^1.131.27",
"@tanstack/react-router-devtools": "^1.131.27",
"@tanstack/router-plugin": "^1.131.27",
"@types/react": "^19.1.11",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.1",
"eslint": "^9.34.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"typescript-eslint": "^8.40.0",
"vite": "^7.1.3"
"@tanstack/react-router": "^1.132.31",
"@tanstack/react-router-devtools": "^1.132.31",
"@tanstack/router-plugin": "^1.132.31",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"@vitejs/plugin-react": "^5.0.4",
"globals": "^16.4.0",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"type-fest": "^5.0.1",
"vite": "^7.1.8"
},
"dependencies": {
"@effect/platform": "^0.90.6",
"@effect/platform-browser": "^0.70.0",
"@effect/platform": "^0.92.1",
"@effect/platform-browser": "^0.72.0",
"@radix-ui/themes": "^3.2.1",
"@typed/async-data": "^0.13.1",
"@typed/id": "^0.17.2",
"@typed/lazy-ref": "^0.3.3",
"effect": "^3.17.9",
"effect": "^3.18.1",
"effect-fc": "workspace:*",
"react-icons": "^5.5.0"
},
"overrides": {
"@types/react": "^19.1.11",
"effect": "^3.17.9",
"react": "^19.1.1"
"@types/react": "^19.2.0",
"effect": "^3.18.1",
"react": "^19.2.0"
}
}

View File

@@ -0,0 +1,77 @@
import { Callout, Flex, Spinner, Switch, TextField } from "@radix-ui/themes"
import { Array, Option } from "effect"
import { Component, Form, Hooks } from "effect-fc"
interface Props
extends TextField.RootProps, Form.useInput.Options {
readonly optional?: false
readonly field: Form.FormField<any, string>
}
interface OptionalProps
extends Omit<TextField.RootProps, "defaultValue">, Form.useOptionalInput.Options<string> {
readonly optional: true
readonly field: Form.FormField<any, Option.Option<string>>
}
export type TextFieldFormInputProps = Props | OptionalProps
export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(
function*(props: TextFieldFormInputProps) {
const input: (
| { readonly optional: true } & Form.useOptionalInput.Result<string>
| { readonly optional: false } & Form.useInput.Result<string>
) = props.optional
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
? { optional: true, ...yield* Form.useOptionalInput(props.field, props) }
// biome-ignore lint/correctness/useHookAtTopLevel: "optional" reactivity not supported
: { optional: false, ...yield* Form.useInput(props.field, props) }
const [issues, isValidating, isSubmitting] = yield* Hooks.useSubscribables(
props.field.issuesSubscribable,
props.field.isValidatingSubscribable,
props.field.isSubmittingSubscribable,
)
return (
<Flex direction="column" gap="1">
<TextField.Root
value={input.value}
onChange={e => input.setValue(e.target.value)}
disabled={(input.optional && !input.enabled) || isSubmitting}
{...props}
>
{input.optional &&
<TextField.Slot side="left">
<Switch
size="1"
checked={input.enabled}
onCheckedChange={input.setEnabled}
/>
</TextField.Slot>
}
{isValidating &&
<TextField.Slot side="right">
<Spinner />
</TextField.Slot>
}
{props.children}
</TextField.Root>
{Option.match(Array.head(issues), {
onSome: issue => (
<Callout.Root>
<Callout.Text>{issue.message}</Callout.Text>
</Callout.Root>
),
onNone: () => <></>,
})}
</Flex>
)
}
) {}

View File

@@ -1,7 +1,8 @@
import { Callout, Flex, TextArea, TextAreaProps } from "@radix-ui/themes"
import { Array, Equivalence, Option, ParseResult, Schema, Struct } from "effect"
/** biome-ignore-all lint/correctness/useHookAtTopLevel: effect-fc HOC */
import { Callout, Flex, TextArea, type TextAreaProps } from "@radix-ui/themes"
import { Array, type Equivalence, Option, ParseResult, type Schema, Struct } from "effect"
import { Component } from "effect-fc"
import { useInput } from "effect-fc/hooks"
import { useInput } from "effect-fc/Hooks"
import * as React from "react"
@@ -15,7 +16,7 @@ export const TextAreaInput = <A, R>(options: {
React.JSX.Element,
ParseResult.ParseError,
R
> => Component.makeUntraced(function* TextFieldInput(props) {
> => Component.makeUntraced("TextFieldInput")(function*(props) {
const input = yield* useInput({ ...options, ...props })
const issue = React.useMemo(() => input.error.pipe(
Option.map(ParseResult.ArrayFormatter.formatErrorSync),

View File

@@ -1,7 +1,8 @@
/** biome-ignore-all lint/correctness/useHookAtTopLevel: effect-fc HOC */
import { Callout, Checkbox, Flex, TextField } from "@radix-ui/themes"
import { Array, Equivalence, Option, ParseResult, Schema, Struct } from "effect"
import { Component, Memo } from "effect-fc"
import { useInput, useOptionalInput } from "effect-fc/hooks"
import { Array, type Equivalence, Option, ParseResult, type Schema, Struct } from "effect"
import { Component } from "effect-fc"
import { useInput, useOptionalInput } from "effect-fc/Hooks"
import * as React from "react"
@@ -18,7 +19,7 @@ export const TextFieldInput = <A, R, O extends boolean = false>(options: {
readonly optional?: O
readonly schema: Schema.Schema<A, string, R>
readonly equivalence?: Equivalence.Equivalence<A>
}) => Component.makeUntraced(function* TextFieldInput(props: O extends true
}) => Component.makeUntraced("TextFieldInput")(function*(props: O extends true
? TextFieldOptionalInputProps<A, R>
: TextFieldInputProps<A, R>
) {
@@ -28,12 +29,10 @@ export const TextFieldInput = <A, R, O extends boolean = false>(options: {
) = options.optional
? {
optional: true,
// eslint-disable-next-line react-hooks/rules-of-hooks
...yield* useOptionalInput({ ...options, ...props as TextFieldOptionalInputProps<A, R> }),
}
: {
optional: false,
// eslint-disable-next-line react-hooks/rules-of-hooks
...yield* useInput({ ...options, ...props as TextFieldInputProps<A, R> }),
}
@@ -67,4 +66,4 @@ export const TextFieldInput = <A, R, O extends boolean = false>(options: {
}
</Flex>
)
}).pipe(Memo.memo)
})

View File

@@ -14,6 +14,7 @@ declare module "@tanstack/react-router" {
}
}
// biome-ignore lint/style/noNonNullAssertion: React entrypoint
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime}>

View File

@@ -9,12 +9,18 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevInputRouteImport } from './routes/dev/input'
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
const FormRoute = FormRouteImport.update({
id: '/form',
path: '/form',
getParentRoute: () => rootRouteImport,
} as any)
const BlankRoute = BlankRouteImport.update({
id: '/blank',
path: '/blank',
@@ -44,6 +50,7 @@ const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
@@ -51,6 +58,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
@@ -59,6 +67,7 @@ export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/input': typeof DevInputRoute
'/dev/memo': typeof DevMemoRoute
@@ -68,15 +77,23 @@ export interface FileRouteTypes {
fullPaths:
| '/'
| '/blank'
| '/form'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/blank' | '/dev/async-rendering' | '/dev/input' | '/dev/memo'
to:
| '/'
| '/blank'
| '/form'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
id:
| '__root__'
| '/'
| '/blank'
| '/form'
| '/dev/async-rendering'
| '/dev/input'
| '/dev/memo'
@@ -85,6 +102,7 @@ export interface FileRouteTypes {
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevInputRoute: typeof DevInputRoute
DevMemoRoute: typeof DevMemoRoute
@@ -92,6 +110,13 @@ export interface RootRouteChildren {
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/form': {
id: '/form'
path: '/form'
fullPath: '/form'
preLoaderRoute: typeof FormRouteImport
parentRoute: typeof rootRouteImport
}
'/blank': {
id: '/blank'
path: '/blank'
@@ -133,6 +158,7 @@ declare module '@tanstack/react-router' {
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
BlankRoute: BlankRoute,
FormRoute: FormRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevInputRoute: DevInputRoute,
DevMemoRoute: DevMemoRoute,

View File

@@ -1,11 +1,10 @@
import { runtime } from "@/runtime"
import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect } from "effect"
import { Component, Memo, Suspense } from "effect-fc"
import { Hooks } from "effect-fc/hooks"
import { Async, Component, Hooks, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
// Generator version
@@ -51,7 +50,7 @@ const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
// )
class AsyncComponent extends Component.makeUntraced(function* AsyncComponent() {
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
const SubComponentFC = yield* SubComponent
yield* Effect.sleep("500 millis") // Async operation
@@ -64,12 +63,12 @@ class AsyncComponent extends Component.makeUntraced(function* AsyncComponent() {
</Flex>
)
}).pipe(
Suspense.suspense,
Suspense.withOptions({ defaultFallback: <p>Loading...</p> }),
Async.async,
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
) {}
class MemoizedAsyncComponent extends Memo.memo(AsyncComponent) {}
class MemoizedAsyncComponent extends Memoized.memoized(AsyncComponent) {}
class SubComponent extends Component.makeUntraced(function* SubComponent() {
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
const [state] = React.useState(yield* Hooks.useOnce(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
}) {}

View File

@@ -1,10 +1,9 @@
import { TextFieldInput } from "@/lib/input/TextFieldInput"
import { runtime } from "@/runtime"
import { Container } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Schema, SubscriptionRef } from "effect"
import { Component, Memo } from "effect-fc"
import { useInput, useOnce, useRefState } from "effect-fc/hooks"
import { Component, Hooks, Memoized } from "effect-fc"
import { TextFieldInput } from "@/lib/input/TextFieldInput"
import { runtime } from "@/runtime"
const IntFromString = Schema.NumberFromString.pipe(Schema.int())
@@ -12,18 +11,18 @@ const IntFromString = Schema.NumberFromString.pipe(Schema.int())
const IntTextFieldInput = TextFieldInput({ schema: IntFromString })
const StringTextFieldInput = TextFieldInput({ schema: Schema.String })
const Input = Component.makeUntraced(function* Input() {
const Input = Component.makeUntraced("Input")(function*() {
const IntTextFieldInputFC = yield* IntTextFieldInput
const StringTextFieldInputFC = yield* StringTextFieldInput
const intRef1 = yield* useOnce(() => SubscriptionRef.make(0))
const intRef2 = yield* useOnce(() => SubscriptionRef.make(0))
const stringRef = yield* useOnce(() => SubscriptionRef.make(""))
const intRef1 = yield* Hooks.useOnce(() => SubscriptionRef.make(0))
// const intRef2 = yield* useOnce(() => SubscriptionRef.make(0))
const stringRef = yield* Hooks.useOnce(() => SubscriptionRef.make(""))
// yield* useFork(() => Stream.runForEach(intRef1.changes, Console.log), [intRef1])
const input2 = yield* useInput({ schema: IntFromString, ref: intRef2 })
// const input2 = yield* useInput({ schema: IntFromString, ref: intRef2 })
const [str, setStr] = yield* useRefState(stringRef)
// const [str, setStr] = yield* useRefState(stringRef)
return (
<Container>
@@ -33,7 +32,7 @@ const Input = Component.makeUntraced(function* Input() {
</Container>
)
}).pipe(
Memo.memo,
Memoized.memoized,
Component.withRuntime(runtime.context)
)

View File

@@ -1,13 +1,13 @@
import { runtime } from "@/runtime"
import { Flex, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Effect } from "effect"
import { Component, Memo } from "effect-fc"
import { Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
const RouteComponent = Component.makeUntraced(function* RouteComponent() {
const RouteComponent = Component.makeUntraced("RouteComponent")(function*() {
const [value, setValue] = React.useState("")
return (
@@ -25,12 +25,12 @@ const RouteComponent = Component.makeUntraced(function* RouteComponent() {
Component.withRuntime(runtime.context)
)
class SubComponent extends Component.makeUntraced(function* SubComponent() {
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
const id = yield* makeUuid4.pipe(Effect.provide(GetRandomValues.CryptoRandom))
return <Text>{id}</Text>
}) {}
class MemoizedSubComponent extends Memo.memo(SubComponent) {}
class MemoizedSubComponent extends Memoized.memoized(SubComponent) {}
export const Route = createFileRoute("/dev/memo")({
component: RouteComponent,

View File

@@ -0,0 +1,99 @@
import { Button, Container, Flex } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Option, ParseResult, Schema } from "effect"
import { Component, Form, Hooks } from "effect-fc"
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime"
const email = Schema.pattern<typeof Schema.String>(
/^(?!\.)(?!.*\.\.)([A-Z0-9_+-.]*)[A-Z0-9_+-]@([A-Z0-9][A-Z0-9-]*\.)+[A-Z]{2,}$/i,
{
identifier: "email",
title: "email",
message: () => "Not an email address",
},
)
const RegisterFormSchema = Schema.Struct({
email: Schema.String.pipe(email),
password: Schema.String.pipe(Schema.minLength(3)),
birth: Schema.OptionFromSelf(DateTimeUtcFromZonedInput),
})
class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
scoped: Form.service({
schema: RegisterFormSchema.pipe(
Schema.compose(
Schema.transformOrFail(
Schema.typeSchema(RegisterFormSchema),
Schema.typeSchema(RegisterFormSchema),
{
decode: v => Effect.andThen(Effect.sleep("500 millis"), ParseResult.succeed(v)),
encode: ParseResult.succeed,
},
),
),
),
initialEncodedValue: { email: "", password: "", birth: Option.none() },
submit: v => Effect.sleep("500 millis").pipe(
Effect.andThen(Console.log(v)),
Effect.andThen(Effect.sync(() => alert("Done!"))),
),
debounce: "500 millis",
})
}) {}
class RegisterPage extends Component.makeUntraced("RegisterPage")(function*() {
const form = yield* RegisterForm
const submit = yield* Form.useSubmit(form)
const [canSubmit] = yield* Hooks.useSubscribables(form.canSubmitSubscribable)
const TextFieldFormInputFC = yield* TextFieldFormInput
return (
<Container width="300">
<form onSubmit={e => {
e.preventDefault()
void submit()
}}>
<Flex direction="column" gap="2">
<TextFieldFormInputFC
field={Form.useField(form, ["email"])}
/>
<TextFieldFormInputFC
field={Form.useField(form, ["password"])}
/>
<TextFieldFormInputFC
optional
type="datetime-local"
field={Form.useField(form, ["birth"])}
defaultValue=""
/>
<Button disabled={!canSubmit}>Submit</Button>
</Flex>
</form>
</Container>
)
}) {}
export const Route = createFileRoute("/form")({
component: Component.makeUntraced("RegisterRoute")(function*() {
const RegisterRouteFC = yield* Effect.provide(
RegisterPage,
yield* Hooks.useContext(RegisterForm.Default, { finalizerExecutionMode: "fork" }),
)
return <RegisterRouteFC />
}).pipe(
Component.withRuntime(runtime.context)
)
})

View File

@@ -1,17 +1,18 @@
import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect"
import { Component, Hooks } from "effect-fc"
import { runtime } from "@/runtime"
import { Todos } from "@/todo/Todos"
import { TodosState } from "@/todo/TodosState.service"
import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect"
import { Component } from "effect-fc"
import { useContext } from "effect-fc/hooks"
const TodosStateLive = TodosState.Default("todos")
const Index = Component.makeUntraced(function* Index() {
const context = yield* useContext(TodosStateLive, { finalizerExecutionMode: "fork" })
const TodosFC = yield* Effect.provide(Todos, context)
const Index = Component.makeUntraced("Index")(function*() {
const TodosFC = yield* Effect.provide(
Todos,
yield* Hooks.useContext(TodosStateLive, { finalizerExecutionMode: "fork" }),
)
return <TodosFC />
}).pipe(

View File

@@ -1,15 +1,13 @@
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, DateTime, Effect, Match, Option, Ref, Runtime, Schema, Stream, SubscriptionRef } from "effect"
import { Component, Hooks, Memoized, Subscribable, SubscriptionSubRef } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import * as Domain from "@/domain"
import { TextAreaInput } from "@/lib/input/TextAreaInput"
import { TextFieldInput } from "@/lib/input/TextFieldInput"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { Box, Button, Flex, IconButton } from "@radix-ui/themes"
import { GetRandomValues, makeUuid4 } from "@typed/id"
import { Chunk, DateTime, Effect, Match, Option, Ref, Runtime, Schema, Stream, SubscriptionRef } from "effect"
import { Component, Memo } from "effect-fc"
import { useMemo, useOnce, useSubscribe } from "effect-fc/hooks"
import { Subscribable, SubscriptionSubRef } from "effect-fc/types"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import { TodosState } from "./TodosState.service"
@@ -31,11 +29,11 @@ export type TodoProps = (
| { readonly _tag: "edit", readonly id: string }
)
export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps) {
export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoProps) {
const runtime = yield* Effect.runtime()
const state = yield* TodosState
const { ref, indexRef, contentRef, completedAtRef } = yield* useMemo(() => Match.value(props).pipe(
const { ref, indexRef, contentRef, completedAtRef } = yield* Hooks.useMemo(() => Match.value(props).pipe(
Match.tag("new", () => Effect.Do.pipe(
Effect.bind("ref", () => Effect.andThen(makeTodo, SubscriptionRef.make)),
Effect.let("indexRef", () => Subscribable.make({ get: Effect.succeed(-1), changes: Stream.empty })),
@@ -48,10 +46,9 @@ export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps
Effect.let("contentRef", ({ ref }) => SubscriptionSubRef.makeFromPath(ref, ["content"])),
Effect.let("completedAtRef", ({ ref }) => SubscriptionSubRef.makeFromPath(ref, ["completedAt"])),
// eslint-disable-next-line react-hooks/exhaustive-deps
), [props._tag, props._tag === "edit" ? props.id : undefined])
const [index, size] = yield* useSubscribe(indexRef, state.sizeSubscribable)
const [index, size] = yield* Hooks.useSubscribables(indexRef, state.sizeSubscribable)
const StringTextAreaInputFC = yield* StringTextAreaInput
const OptionalDateTimeInputFC = yield* OptionalDateTimeInput
@@ -67,7 +64,7 @@ export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps
<OptionalDateTimeInputFC
type="datetime-local"
ref={completedAtRef}
defaultValue={yield* useOnce(() => DateTime.now)}
defaultValue={yield* Hooks.useOnce(() => DateTime.now)}
/>
{props._tag === "new" &&
@@ -109,4 +106,6 @@ export class Todo extends Component.makeUntraced(function* Todo(props: TodoProps
}
</Flex>
)
}).pipe(Memo.memo) {}
}).pipe(
Memoized.memoized
) {}

View File

@@ -1,16 +1,15 @@
import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect"
import { Component } from "effect-fc"
import { useOnce, useSubscribe } from "effect-fc/hooks"
import { Component, Hooks } from "effect-fc"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
export class Todos extends Component.makeUntraced(function* Todos() {
export class Todos extends Component.makeUntraced("Todos")(function*() {
const state = yield* TodosState
const [todos] = yield* useSubscribe(state.ref)
const [todos] = yield* Hooks.useSubscribables(state.ref)
yield* useOnce(() => Effect.andThen(
yield* Hooks.useOnce(() => Effect.andThen(
Console.log("Todos mounted"),
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))

View File

@@ -1,8 +1,8 @@
import { Todo } from "@/domain"
import { KeyValueStore } from "@effect/platform"
import { BrowserKeyValueStore } from "@effect/platform-browser"
import { Chunk, Console, Effect, Option, Schema, Stream, SubscriptionRef } from "effect"
import { Subscribable, SubscriptionSubRef } from "effect-fc/types"
import { Subscribable, SubscriptionSubRef } from "effect-fc"
import { Todo } from "@/domain"
export class TodosState extends Effect.Service<TodosState>()("TodosState", {

View File

@@ -22,15 +22,15 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"plugins": [
{
"name": "@effect/language-service"
}
{ "name": "@effect/language-service" }
]
},
"include": ["src"]
}

View File

@@ -20,5 +20,6 @@
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}