Merge branch 'next' into docs
All checks were successful
Lint / lint (push) Successful in 14s

This commit is contained in:
Julien Valverdé
2026-03-11 21:53:17 +01:00
17 changed files with 734 additions and 256 deletions

View File

@@ -1,31 +1,47 @@
/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */
import { Effect, Function, Predicate, Runtime, Scope } from "effect"
import { Effect, type Equivalence, Function, Predicate, Runtime, Scope } from "effect"
import * as React from "react"
import * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type TypeId = typeof TypeId
export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async")
export type AsyncTypeId = typeof AsyncTypeId
export interface Async extends AsyncOptions {
readonly [TypeId]: TypeId
/**
* A trait for `Component`'s that allows them running asynchronous effects.
*/
export interface Async extends AsyncPrototype, AsyncOptions {}
export interface AsyncPrototype {
readonly [AsyncTypeId]: AsyncTypeId
}
/**
* Configuration options for `Async` components.
*/
export interface AsyncOptions {
/**
* The default fallback React node to display while the async operation is pending.
* Used if no fallback is provided to the component when rendering.
*/
readonly defaultFallback?: React.ReactNode
}
/**
* Props for `Async` components.
*/
export type AsyncProps = Omit<React.SuspenseProps, "children">
export const AsyncPrototype = Object.freeze({
[TypeId]: TypeId,
export const AsyncPrototype: AsyncPrototype = Object.freeze({
[AsyncTypeId]: AsyncTypeId,
asFunctionComponent<P extends {}, A extends React.ReactNode, E, R>(
this: Component.Component<P, A, E, R> & Async,
runtimeRef: React.RefObject<Runtime.Runtime<Exclude<R, Scope.Scope>>>,
) {
const SuspenseInner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
const Inner = (props: { readonly promise: Promise<React.ReactNode> }) => React.use(props.promise)
return ({ fallback, name, ...props }: AsyncProps) => {
const promise = Runtime.runPromise(runtimeRef.current)(
@@ -38,17 +54,64 @@ export const AsyncPrototype = Object.freeze({
return React.createElement(
React.Suspense,
{ fallback: fallback ?? this.defaultFallback, name },
React.createElement(SuspenseInner, { promise }),
React.createElement(Inner, { promise }),
)
}
},
} as const)
/**
* An equivalence function for comparing `AsyncProps` that ignores the `fallback` property.
* Used by default by async components with `Memoized.memoized` applied.
*/
export const defaultPropsEquivalence: Equivalence.Equivalence<AsyncProps> = (
self: Record<string, unknown>,
that: Record<string, unknown>,
) => {
if (self === that)
return true
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId)
for (const key in self) {
if (key === "fallback")
continue
if (!(key in that) || !Object.is(self[key], that[key]))
return false
}
for (const key in that) {
if (key === "fallback")
continue
if (!(key in self))
return false
}
return true
}
export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId)
/**
* Converts a Component into an `Async` component that supports running asynchronous effects.
*
* Note: The component cannot have a prop named "promise" as it's reserved for internal use.
*
* @param self - The component to convert to an Async component
* @returns A new `Async` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* )
* ```
*/
export const async = <T extends Component.Component<any, any, any, any>>(
self: T
self: T & (
"promise" extends keyof Component.Component.Props<T>
? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else."
: T
)
): (
& Omit<T, keyof Component.Component.AsComponent<T>>
& Component.Component<
@@ -59,13 +122,37 @@ export const async = <T extends Component.Component<any, any, any, any>>(
>
& Async
) => Object.setPrototypeOf(
Object.assign(function() {}, self),
Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }),
Object.freeze(Object.setPrototypeOf(
Object.assign({}, AsyncPrototype),
Object.getPrototypeOf(self),
)),
)
/**
* Applies options to an Async component, returning a new Async component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Async component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns An Async component with the applied options
*
* @example
* ```ts
* // Curried
* const MyAsyncComponent = MyComponent.pipe(
* Async.async,
* Async.withOptions({ defaultFallback: <p>Loading...</p> }),
* )
*
* // Uncurried
* const MyAsyncComponent = Async.withOptions(
* Async.async(MyComponent),
* { defaultFallback: <p>Loading...</p> },
* )
* ```
*/
export const withOptions: {
<T extends Component.Component<any, any, any, any> & Async>(
options: Partial<AsyncOptions>

View File

@@ -4,20 +4,16 @@ import { Context, type Duration, Effect, Equivalence, ExecutionStrategy, Exit, F
import * as React from "react"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
export type TypeId = typeof TypeId
export const ComponentTypeId: unique symbol = Symbol.for("@effect-fc/Component/Component")
export type ComponentTypeId = typeof ComponentTypeId
/**
* Interface representing an Effect-based React Component.
*
* This is both:
* - an Effect that produces a React function component
* - a constructor-like object with component metadata and options
* Represents an Effect-based React Component that integrates the Effect system with React.
*/
export interface Component<P extends {}, A extends React.ReactNode, E, R>
extends ComponentPrototype<P, A, R>, ComponentOptions {
new(_: never): Record<string, never>
readonly [TypeId]: TypeId
readonly [ComponentTypeId]: ComponentTypeId
readonly "~Props": P
readonly "~Success": A
readonly "~Error": E
@@ -38,7 +34,7 @@ export declare namespace Component {
export interface ComponentPrototype<P extends {}, A extends React.ReactNode, R>
extends Pipeable.Pipeable {
readonly [TypeId]: TypeId
readonly [ComponentTypeId]: ComponentTypeId
readonly use: Effect.Effect<(props: P) => A, never, Exclude<R, Scope.Scope>>
asFunctionComponent(
@@ -50,7 +46,7 @@ extends Pipeable.Pipeable {
}
export const ComponentPrototype: ComponentPrototype<any, any, any> = Object.freeze({
[TypeId]: TypeId,
[ComponentTypeId]: ComponentTypeId,
...Pipeable.Prototype,
get use() { return use(this) },
@@ -92,39 +88,50 @@ const use = Effect.fnUntraced(function* <P extends {}, A extends React.ReactNode
}),
Equivalence.array(Equivalence.strict()),
)))[0](Array.from(
Context.omit(...nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
Context.omit(...self.nonReactiveTags)(runtimeRef.current.context).unsafeMap.values()
))
})
export interface ComponentOptions {
/** Custom displayName for React DevTools and debugging. */
/**
* Custom display name for the component in React DevTools and debugging utilities.
*/
readonly displayName?: string
/**
* Strategy used when executing finalizers on unmount/scope close.
* Context tags that should not trigger component remount when their values change.
*
* @default [Tracer.ParentSpan]
*/
readonly nonReactiveTags: readonly Context.Tag<any, any>[]
/**
* Specifies the execution strategy for finalizers when the component unmounts or its scope closes.
* Determines whether finalizers execute sequentially or in parallel.
*
* @default ExecutionStrategy.sequential
*/
readonly finalizerExecutionStrategy: ExecutionStrategy.ExecutionStrategy
/**
* Debounce time before executing finalizers after component unmount.
* Helps avoid unnecessary work during fast remount/remount cycles.
* Debounce duration before executing finalizers after component unmount.
* Prevents unnecessary cleanup work during rapid remount/unmount cycles,
* which is common in development and certain UI patterns.
*
* @default "100 millis"
*/
readonly finalizerExecutionDebounce: Duration.DurationInput
}
export const defaultOptions: ComponentOptions = {
nonReactiveTags: [Tracer.ParentSpan],
finalizerExecutionStrategy: ExecutionStrategy.sequential,
finalizerExecutionDebounce: "100 millis",
}
export const nonReactiveTags = [Tracer.ParentSpan] as const
export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, TypeId)
export const isComponent = (u: unknown): u is Component<{}, React.ReactNode, unknown, unknown> => Predicate.hasProperty(u, ComponentTypeId)
export declare namespace make {
export type Gen = {
@@ -352,17 +359,51 @@ export declare namespace make {
}
/**
* Creates an Effect-FC Component following the same overloads and pipeline style as `Effect.fn`.
* Creates an Effect-FC Component using the same overloads and pipeline composition style as `Effect.fn`.
*
* This is the **recommended** way to define components. It supports:
* - Generator syntax (yield* style) — most ergonomic and readable
* - Direct Effect return (non-generator)
* - Chained transformation functions (like Effect.fn pipelines)
* - Optional tracing span with automatic `displayName`
* This is the **recommended** approach for defining Effect-FC components. It provides comprehensive
* support for multiple component definition patterns:
*
* When you provide a `spanName` as the first argument, two things happen automatically:
* 1. A tracing span is created with that name (unless using `makeUntraced`)
* 2. The resulting React component gets `displayName = spanName`
* - **Generator syntax** (yield* style): Most ergonomic and readable approach for sequential operations
* - **Direct Effect return**: For simple components that return an Effect directly
* - **Chained transformation functions**: Enables Effect.fn-style pipelines for composable transformations
* - **Automatic tracing**: Optional tracing span creation with automatic `displayName` assignment
*
* When a `spanName` string is provided, the following occurs automatically:
* 1. A distributed tracing span is created with the specified name
* 2. The resulting React component receives `displayName = spanName` for DevTools visibility
*
* @example
* ```tsx
* const MyComponent = Component.make("MyComponent")(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* })
* ```
*
* @example As an opaque type using class syntax
* ```tsx
* class MyComponent extends Component.make("MyComponent")(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* }) {}
* ```
*
* @example Without name
* ```tsx
* class MyComponent extends Component.make(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* }) {}
* ```
*
* @example Using pipeline
* ```tsx
* class MyComponent extends Component.make("MyComponent")(
* (props: { count: number }) => someEffect,
* Effect.map(value => <div>{value}</div>),
* ) {}
* ```
*/
export const make: (
& make.Gen
@@ -393,15 +434,50 @@ export const make: (
}
/**
* Same as `make`, but creates an **untraced** version — no automatic tracing span is created.
* Creates an Effect-FC Component without automatic distributed tracing.
*
* Follows the exact same API shape as `Effect.fnUntraced`.
* Useful for:
* - Components where you want full manual control over tracing
* - Avoiding span noise in deeply nested UI
* This function provides the same API surface as `make`, but does not create automatic tracing spans.
* It follows the exact same overload structure as `Effect.fnUntraced`.
*
* When a string is provided as first argument, it is **only** used as the React component's `displayName`
* (no tracing span is created).
* Use this variant when you need:
* - Full manual control over tracing instrumentation
* - To reduce tracing overhead in deeply nested component hierarchies
* - To avoid span noise in performance-sensitive applications
*
* When a `spanName` string is provided, it is used **exclusively** as the React component's
* `displayName` for DevTools identification. No tracing span is created.
*
* @example
* ```tsx
* const MyComponent = Component.makeUntraced("MyComponent")(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* })
* ```
*
* @example As an opaque type using class syntax
* ```tsx
* class MyComponent extends Component.makeUntraced("MyComponent")(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* }) {}
* ```
*
* @example Without name
* ```tsx
* class MyComponent extends Component.makeUntraced(function* (props: { count: number }) {
* const value = yield* someEffect
* return <div>{value}</div>
* }) {}
* ```
*
* @example Using pipeline
* ```tsx
* class MyComponent extends Component.makeUntraced("MyComponent")(
* (props: { count: number }) => someEffect,
* Effect.map(value => <div>{value}</div>),
* ) {}
* ```
*/
export const makeUntraced: (
& make.Gen
@@ -425,7 +501,20 @@ export const makeUntraced: (
)
/**
* Creates a new component with modified options while preserving original behavior.
* Creates a new component with modified configuration options while preserving all original behavior.
*
* This function allows you to customize component-level options such as finalizer execution strategy
* and debounce timing.
*
* @example
* ```tsx
* const MyComponentWithCustomOptions = MyComponent.pipe(
* Component.withOptions({
* finalizerExecutionStrategy: ExecutionStrategy.parallel,
* finalizerExecutionDebounce: "50 millis",
* })
* )
* ```
*/
export const withOptions: {
<T extends Component<any, any, any, any>>(
@@ -444,21 +533,27 @@ export const withOptions: {
))
/**
* Wraps an Effect-FC `Component` and turns it into a regular React function component
* that serves as an **entrypoint** into an Effect-FC component hierarchy.
* Wraps an Effect-FC Component and converts it into a standard React function component,
* serving as an **entrypoint** into an Effect-FC component hierarchy.
*
* This is the recommended way to connect Effect-FC components to the rest of your React app,
* especially when using routers (TanStack Router, React Router, etc.), lazy-loaded routes,
* or any place where a standard React component is expected.
* This is how Effect-FC components are integrated with the broader React ecosystem,
* particularly when:
* - Using client-side routers (TanStack Router, React Router, etc.)
* - Implementing lazy-loaded or code-split routes
* - Connecting to third-party libraries expecting standard React components
* - Creating component boundaries between Effect-FC and non-Effect-FC code
*
* The runtime is obtained from the provided React Context, allowing you to:
* - Provide dependencies once at a high level
* - Use the same runtime across an entire route tree or feature
* The Effect runtime is obtained from the provided React Context.
*
* @example Using TanStack Router
* @param self - The Effect-FC Component to be rendered as a standard React component
* @param context - React Context providing the Effect Runtime for this component tree.
* Create this using the `ReactRuntime` module.
*
* @example Integration with TanStack Router
* ```tsx
* // Main
* // Application root
* export const runtime = ReactRuntime.make(Layer.empty)
*
* function App() {
* return (
* <ReactRuntime.Provider runtime={runtime}>
@@ -467,14 +562,12 @@ export const withOptions: {
* )
* }
*
* // Route
* // Route definition
* export const Route = createFileRoute("/")({
* component: Component.withRuntime(HomePage, runtime.context)
* })
* ```
*
* @param self - The Effect-FC Component you want to render as a regular React component.
* @param context - React Context that holds the Runtime to use for this component tree. See the `ReactRuntime` module to create one.
*/
export const withRuntime: {
<P extends {}, A extends React.ReactNode, E, R>(
@@ -496,8 +589,10 @@ export const withRuntime: {
/**
* Service that keeps track of scopes associated with React components
* (used internally by the `useScope` hook).
* Internal Effect service that maintains a registry of scopes associated with React component instances.
*
* This service is used internally by the `useScope` hook to manage the lifecycle of component scopes,
* including tracking active scopes and coordinating their cleanup when components unmount or dependencies change.
*/
export class ScopeMap extends Effect.Service<ScopeMap>()("@effect-fc/Component/ScopeMap", {
effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty<object, ScopeMap.Entry>()))
@@ -519,12 +614,21 @@ export declare namespace useScope {
}
/**
* Hook that creates and manages a `Scope` for the current component instance.
* Effect hook that creates and manages a `Scope` for the current component instance.
*
* Automatically closes the scope whenever `deps` changes or the component unmounts.
* This hook establishes a new scope that is automatically closed when:
* - The component unmounts
* - The dependency array `deps` changes
*
* @param deps - dependency array like in `React.useEffect`
* @param options - finalizer execution control
* The scope provides a resource management boundary for any Effects executed within the component,
* ensuring proper cleanup of resources and execution of finalizers.
*
* @param deps - Dependency array following React.useEffect semantics. The scope is recreated
* whenever any dependency changes.
* @param options - Configuration for finalizer execution behavior, including execution strategy
* and debounce timing.
*
* @returns An Effect that produces a `Scope` for resource management
*/
export const useScope = Effect.fnUntraced(function*(
deps: React.DependencyList,
@@ -579,7 +683,23 @@ export const useScope = Effect.fnUntraced(function*(
})
/**
* Runs an effect and returns its result only once on component mount.
* Effect hook that executes an Effect once when the component mounts and caches the result.
*
* This hook is useful for one-time initialization logic that should not be re-executed
* when the component re-renders. The Effect is executed exactly once during the component's
* initial mount, and the cached result is returned on all subsequent renders.
*
* @param f - A function that returns the Effect to execute on mount
*
* @returns An Effect that produces the cached result of the Effect
*
* @example
* ```tsx
* const MyComponent = Component.make(function*() {
* const initialData = yield* Component.useOnMount(() => getData)
* return <div>{initialData}</div>
* })
* ```
*/
export const useOnMount = Effect.fnUntraced(function* <A, E, R>(
f: () => Effect.Effect<A, E, R>
@@ -593,9 +713,33 @@ export declare namespace useOnChange {
}
/**
* Runs an effect and returns its result whenever dependencies change.
* Effect hook that executes an Effect whenever dependencies change and caches the result.
*
* Provides its own `Scope` which closes whenever `deps` changes or the component unmounts.
* This hook combines the dependency-tracking behavior of React.useEffect with Effect caching.
* The Effect is re-executed whenever any dependency in the `deps` array changes, and the result
* is cached until the next dependency change.
*
* A dedicated scope is created for each dependency change, ensuring proper resource cleanup:
* - The scope closes when dependencies change
* - The scope closes when the component unmounts
* - All finalizers are executed according to the configured execution strategy
*
* @param f - A function that returns the Effect to execute
* @param deps - Dependency array following React.useEffect semantics
* @param options - Configuration for scope and finalizer behavior
*
* @returns An Effect that produces the cached result of the Effect
*
* @example
* ```tsx
* const MyComponent = Component.make(function* (props: { userId: string }) {
* const userData = yield* Component.useOnChange(
* getUser(props.userId),
* [props.userId],
* )
* return <div>{userData.name}</div>
* })
* ```
*/
export const useOnChange = Effect.fnUntraced(function* <A, E, R>(
f: () => Effect.Effect<A, E, R>,
@@ -619,9 +763,36 @@ export declare namespace useReactEffect {
}
/**
* Like `React.useEffect` but accepts an effect.
* Effect hook that provides Effect-based semantics for React.useEffect.
*
* Cleanup logic is handled through the `Scope` API rather than using imperative cleanup.
* This hook bridges React's useEffect with the Effect system, allowing you to use Effects
* for React side effects while maintaining React's dependency tracking and lifecycle semantics.
*
* Unlike React.useEffect which uses imperative cleanup functions, this hook leverages the
* Effect Scope API for resource management. Cleanup logic is expressed declaratively through
* finalizers registered with the scope, providing better composability and error handling.
*
* @param f - A function that returns an Effect to execute as a side effect
* @param deps - Optional dependency array following React.useEffect semantics.
* If omitted, the effect runs after every render.
* @param options - Configuration for finalizer execution mode (sync or fork) and strategy
*
* @returns An Effect that produces void
*
* @example
* ```tsx
* const MyComponent = Component.make(function* (props: { id: string }) {
* yield* Component.useReactEffect(
* () => getNotificationStreamForUser(props.id).pipe(
* Stream.unwrap,
* Stream.runForEach(notification => Console.log(`Notification received: ${ notification }`),
* Effect.forkScoped,
* ),
* [props.id],
* )
* return <div>Subscribed to notifications for {props.id}</div>
* })
* ```
*/
export const useReactEffect = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>,
@@ -660,9 +831,43 @@ export declare namespace useReactLayoutEffect {
}
/**
* Like `React.useReactLayoutEffect` but accepts an effect.
* Effect hook that provides Effect-based semantics for React.useLayoutEffect.
*
* Cleanup logic is handled through the `Scope` API rather than using imperative cleanup.
* This hook is identical to `useReactEffect` but executes synchronously after DOM mutations
* but before the browser paints, following React.useLayoutEffect semantics.
*
* Use this hook when you need to:
* - Measure DOM elements (e.g., for layout calculations)
* - Synchronously update state based on DOM measurements
* - Avoid visual flicker from asynchronous updates
*
* Like `useReactEffect`, cleanup logic is handled through the Effect Scope API rather than
* imperative cleanup functions, providing declarative and composable resource management.
*
* @param f - A function that returns an Effect to execute as a layout side effect
* @param deps - Optional dependency array following React.useLayoutEffect semantics.
* If omitted, the effect runs after every render.
* @param options - Configuration for finalizer execution mode (sync or fork) and strategy
*
* @returns An Effect that produces void
*
* @example
* ```tsx
* const MyComponent = Component.make(function*() {
* const ref = React.useRef<HTMLDivElement>(null)
* yield* Component.useReactLayoutEffect(
* () => Effect.gen(function* () {
* const element = ref.current
* if (element) {
* const rect = element.getBoundingClientRect()
* yield* Console.log(`Element dimensions: ${ rect.width }x${ rect.height }`)
* }
* }),
* [],
* )
* return <div ref={ref}>Content</div>
* })
* ```
*/
export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
f: () => Effect.Effect<void, E, R>,
@@ -675,7 +880,23 @@ export const useReactLayoutEffect = Effect.fnUntraced(function* <E, R>(
})
/**
* Get a synchronous run function for the current runtime context.
* Effect hook that provides a synchronous function to execute Effects within the current runtime context.
*
* This hook returns a function that can execute Effects synchronously, blocking until completion.
* Use this when you need to run Effects from non-Effect code (e.g., event handlers, callbacks)
* within a component.
*
* @returns An Effect that produces a function capable of synchronously executing Effects
*
* @example
* ```tsx
* const MyComponent = Component.make(function*() {
* const runSync = yield* Component.useRunSync<SomeService>() // Specify required services
* const runSync = yield* Component.useRunSync() // Or no service requirements
*
* return <button onClick={() => runSync(someEffect)}>Click me</button>
* })
* ```
*/
export const useRunSync = <R = never>(): Effect.Effect<
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => A,
@@ -684,7 +905,23 @@ export const useRunSync = <R = never>(): Effect.Effect<
> => Effect.andThen(Effect.runtime(), Runtime.runSync)
/**
* Get a Promise-based run function for the current runtime context.
* Effect hook that provides an asynchronous function to execute Effects within the current runtime context.
*
* This hook returns a function that executes Effects asynchronously, returning a Promise that resolves
* with the Effect's result. Use this when you need to run Effects from non-Effect code (e.g., event handlers,
* async callbacks) and want to handle the result asynchronously.
*
* @returns An Effect that produces a function capable of asynchronously executing Effects
*
* @example
* ```tsx
* const MyComponent = Component.make(function*() {
* const runPromise = yield* Component.useRunPromise<SomeService>() // Specify required services
* const runPromise = yield* Component.useRunPromise() // Or no service requirements
*
* return <button onClick={() => runPromise(someEffect)}>Click me</button>
* })
* ```
*/
export const useRunPromise = <R = never>(): Effect.Effect<
<A, E = never>(effect: Effect.Effect<A, E, Scope.Scope | R>) => Promise<A>,
@@ -693,7 +930,32 @@ export const useRunPromise = <R = never>(): Effect.Effect<
> => Effect.andThen(Effect.runtime(), context => Runtime.runPromise(context))
/**
* Turns a function returning an effect into a memoized synchronous function.
* Effect hook that memoizes a function that returns an Effect, providing synchronous execution.
*
* This hook wraps a function that returns an Effect and returns a memoized version that:
* - Executes the Effect synchronously when called
* - Is memoized based on the provided dependency array
* - Maintains referential equality across renders when dependencies don't change
*
* Use this to create stable callback references for event handlers and other scenarios
* where you need to execute Effects synchronously from non-Effect code.
*
* @param f - A function that accepts arguments and returns an Effect
* @param deps - Dependency array. The memoized function is recreated when dependencies change.
*
* @returns An Effect that produces a memoized function with the same signature as `f`
*
* @example
* ```tsx
* const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) {
* const handleSave = yield* Component.useCallbackSync(
* (data: Data) => Effect.sync(() => props.onSave(data)),
* [props.onSave],
* )
*
* return <button onClick={() => handleSave(myData)}>Save</button>
* })
* ```
*/
export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>,
@@ -708,7 +970,32 @@ export const useCallbackSync = Effect.fnUntraced(function* <Args extends unknown
})
/**
* Turns a function returning an effect into a memoized Promise-based asynchronous function.
* Effect hook that memoizes a function that returns an Effect, providing asynchronous execution.
*
* This hook wraps a function that returns an Effect and returns a memoized version that:
* - Executes the Effect asynchronously when called, returning a Promise
* - Is memoized based on the provided dependency array
* - Maintains referential equality across renders when dependencies don't change
*
* Use this to create stable callback references for async event handlers and other scenarios
* where you need to execute Effects asynchronously from non-Effect code.
*
* @param f - A function that accepts arguments and returns an Effect
* @param deps - Dependency array. The memoized function is recreated when dependencies change.
*
* @returns An Effect that produces a memoized function that returns a Promise
*
* @example
* ```tsx
* const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) {
* const handleSave = yield* Component.useCallbackPromise(
* (data: Data) => Effect.promise(() => props.onSave(data)),
* [props.onSave],
* )
*
* return <button onClick={() => handleSave(myData)}>Save</button>
* })
* ```
*/
export const useCallbackPromise = Effect.fnUntraced(function* <Args extends unknown[], A, E, R>(
f: (...args: Args) => Effect.Effect<A, E, R>,
@@ -727,16 +1014,70 @@ export declare namespace useContext {
}
/**
* Hook that constructs a layer and returns the created context.
* Effect hook that constructs an Effect Layer and returns the resulting context.
*
* The layer gets reconstructed everytime `layer` changes, so make sure its value is stable.
* This hook creates a managed runtime from the provided layer and returns the context it produces.
* The layer is reconstructed whenever its value changes, so ensure the layer reference is stable
* (typically by memoizing it or defining it outside the component).
*
* Building a layer containing asynchronous effects require the component calling this hook to be made async using `Async.async`.
* The hook automatically manages the layer's lifecycle:
* - The layer is built when the component mounts or when the layer reference changes
* - Resources are properly released when the component unmounts or dependencies change
* - Finalizers are executed according to the configured execution strategy
*
* @param layer - The Effect Layer to construct. Should be a stable reference to avoid unnecessary
* reconstruction. Consider memoizing with React.useMemo if defined inline.
* @param options - Configuration for scope and finalizer behavior
*
* @returns An Effect that produces the context created by the layer
*
* @throws If the layer contains asynchronous effects, the component must be wrapped with `Async.async`
*
* @example
* ```tsx
* const MyLayer = Layer.succeed(MyService, new MyServiceImpl())
* const MyComponent = Component.make(function*() {
* const context = yield* Component.useContextFromLayer(MyLayer)
* const Sub = yield* SubComponent.use.pipe(
* Effect.provide(context)
* )
*
* return <Sub />
* })
* ```
*
* @example With memoized layer
* ```tsx
* const MyComponent = Component.make(function*(props: { id: string })) {
* const context = yield* Component.useContextFromLayer(
* React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id])
* )
* const Sub = yield* SubComponent.use.pipe(
* Effect.provide(context)
* )
*
* return <Sub />
* })
* ```
*
* @example With async layer
* ```tsx
* const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect)
* const MyComponent = Component.make(function*() {
* const context = yield* Component.useContextFromLayer(MyAsyncLayer)
* const Sub = yield* SubComponent.use.pipe(
* Effect.provide(context)
* )
*
* return <Sub />
* }).pipe(
* Async.async // Required to handle async layer effects
* )
*/
export const useContext = <ROut, E, RIn>(
export const useContextFromLayer = <ROut, E, RIn>(
layer: Layer.Layer<ROut, E, RIn>,
options?: useContext.Options,
): Effect.Effect<Context.Context<ROut>, E, Exclude<RIn, Scope.Scope>> => useOnChange(() => Effect.context<RIn>().pipe(
): Effect.Effect<Context.Context<ROut>, E, RIn | Scope.Scope> => useOnChange(() => Effect.context<RIn>().pipe(
Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))),
Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)),
Effect.andThen(runtime => runtime.runtimeEffect),

View File

@@ -1,20 +1,20 @@
import { type Cause, Context, Effect, Exit, Layer, Option, Pipeable, Predicate, PubSub, type Queue, type Scope, Supervisor } from "effect"
export const TypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type TypeId = typeof TypeId
export const ErrorObserverTypeId: unique symbol = Symbol.for("@effect-fc/ErrorObserver/ErrorObserver")
export type ErrorObserverTypeId = typeof ErrorObserverTypeId
export interface ErrorObserver<in out E = never> extends Pipeable.Pipeable {
readonly [TypeId]: TypeId
readonly [ErrorObserverTypeId]: ErrorObserverTypeId
handle<A, E, R>(effect: Effect.Effect<A, E, R>): Effect.Effect<A, E, R>
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
}
export const ErrorObserver = <E = never>(): Context.Tag<ErrorObserver, ErrorObserver<E>> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver")
class ErrorObserverImpl<in out E = never>
export class ErrorObserverImpl<in out E = never>
extends Pipeable.Class() implements ErrorObserver<E> {
readonly [TypeId]: TypeId = TypeId
readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId
readonly subscribe: Effect.Effect<Queue.Dequeue<Cause.Cause<E>>, never, Scope.Scope>
constructor(
@@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver<E> {
}
}
class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
readonly value = Effect.void
constructor(readonly pubsub: PubSub.PubSub<Cause.Cause<never>>) {
super()
@@ -43,7 +43,7 @@ class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor<void> {
}
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, TypeId)
export const isErrorObserver = (u: unknown): u is ErrorObserver<unknown> => Predicate.hasProperty(u, ErrorObserverTypeId)
export const layer: Layer.Layer<ErrorObserver> = Layer.unwrapEffect(Effect.map(
PubSub.unbounded<Cause.Cause<never>>(),

View File

@@ -4,20 +4,38 @@ import * as React from "react"
import type * as Component from "./Component.js"
export const TypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type TypeId = typeof TypeId
export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized")
export type MemoizedTypeId = typeof MemoizedTypeId
export interface Memoized<P> extends MemoizedOptions<P> {
readonly [TypeId]: TypeId
/**
* A trait for `Component`'s that uses `React.memo` to optimize re-renders based on prop equality.
*
* @template P The props type of the component
*/
export interface Memoized<P> extends MemoizedPrototype, MemoizedOptions<P> {}
export interface MemoizedPrototype {
readonly [MemoizedTypeId]: MemoizedTypeId
}
/**
* Configuration options for Memoized components.
*
* @template P The props type of the component
*/
export interface MemoizedOptions<P> {
/**
* An optional equivalence function for comparing component props.
* If provided, this function is used by React.memo to determine if props have changed.
* Returns `true` if props are equivalent (no re-render), `false` if they differ (re-render).
*/
readonly propsEquivalence?: Equivalence.Equivalence<P>
}
export const MemoizedPrototype = Object.freeze({
[TypeId]: TypeId,
export const MemoizedPrototype: MemoizedPrototype = Object.freeze({
[MemoizedTypeId]: MemoizedTypeId,
transformFunctionComponent<P extends {}>(
this: Memoized<P>,
@@ -28,8 +46,21 @@ export const MemoizedPrototype = Object.freeze({
} as const)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, TypeId)
export const isMemoized = (u: unknown): u is Memoized<unknown> => Predicate.hasProperty(u, MemoizedTypeId)
/**
* Converts a Component into a `Memoized` component that optimizes re-renders using `React.memo`.
*
* @param self - The component to convert to a Memoized component
* @returns A new `Memoized` component with the same body, error, and context types as the input
*
* @example
* ```ts
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* )
* ```
*/
export const memoized = <T extends Component.Component<any, any, any, any>>(
self: T
): T & Memoized<Component.Component.Props<T>> => Object.setPrototypeOf(
@@ -40,6 +71,30 @@ export const memoized = <T extends Component.Component<any, any, any, any>>(
)),
)
/**
* Applies options to a Memoized component, returning a new Memoized component with the updated configuration.
*
* Supports both curried and uncurried application styles.
*
* @param self - The Memoized component to apply options to (in uncurried form)
* @param options - The options to apply to the component
* @returns A Memoized component with the applied options
*
* @example
* ```ts
* // Curried
* const MyMemoizedComponent = MyComponent.pipe(
* Memoized.memoized,
* Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }),
* )
*
* // Uncurried
* const MyMemoizedComponent = Memoized.withOptions(
* Memoized.memoized(MyComponent),
* { propsEquivalence: (a, b) => a.id === b.id },
* )
* ```
*/
export const withOptions: {
<T extends Component.Component<any, any, any, any> & Memoized<any>>(
options: Partial<MemoizedOptions<Component.Component.Props<T>>>

View File

@@ -6,12 +6,12 @@ import * as Result from "./Result.js"
export const QueryTypeId: unique symbol = Symbol.for("@effect-fc/Query/Query")
export type QueryTypeId = typeof QueryTypeId
export interface Query<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
export interface Query<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
extends Pipeable.Pipeable {
readonly [QueryTypeId]: QueryTypeId
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>
readonly key: Stream.Stream<K>
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: K) => Effect.Effect<A, E, R>
readonly initialProgress: P
@@ -37,13 +37,13 @@ export declare namespace Query {
export type AnyKey = readonly any[]
}
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class() implements Query<K, A, E, R, P> {
export class QueryImpl<in out K extends Query.AnyKey, in out A, in out KE = never, in out KR = never, in out E = never, in out R = never, in out P = never>
extends Pipeable.Class() implements Query<K, A, KE, KR, E, R, P> {
readonly [QueryTypeId]: QueryTypeId = QueryTypeId
constructor(
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | R>,
readonly key: Stream.Stream<K>,
readonly context: Context.Context<Scope.Scope | QueryClient.QueryClient | KR | R>,
readonly key: Stream.Stream<K, KE, KR>,
readonly f: (key: K) => Effect.Effect<A, E, R>,
readonly initialProgress: P,
@@ -77,6 +77,7 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
], { concurrency: "unbounded" }).pipe(
Effect.ignore,
this.runSemaphore.withPermits(1),
Effect.provide(this.context),
)
}
@@ -265,11 +266,11 @@ extends Pipeable.Class() implements Query<K, A, E, R, P> {
}
}
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown, unknown, unknown, unknown> => Predicate.hasProperty(u, QueryTypeId)
export const isQuery = (u: unknown): u is Query<readonly unknown[], unknown> => Predicate.hasProperty(u, QueryTypeId)
export declare namespace make {
export interface Options<K extends Query.AnyKey, A, E = never, R = never, P = never> {
readonly key: Stream.Stream<K>
export interface Options<K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never> {
readonly key: Stream.Stream<K, KE, KR>
readonly f: (key: NoInfer<K>) => Effect.Effect<A, E, Result.forkEffect.InputContext<R, NoInfer<P>>>
readonly initialProgress?: P
readonly staleTime?: Duration.DurationInput
@@ -277,17 +278,17 @@ export declare namespace make {
}
}
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>
): Effect.fn.Return<
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>
> {
const client = yield* QueryClient.QueryClient
return new QueryImpl(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>>(),
return new QueryImpl<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>(
yield* Effect.context<Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>>(),
options.key,
options.f as any,
options.initialProgress as P,
@@ -304,12 +305,12 @@ export const make = Effect.fnUntraced(function* <K extends Query.AnyKey, A, E =
)
})
export const service = <K extends Query.AnyKey, A, E = never, R = never, P = never>(
options: make.Options<K, A, E, R, P>
export const service = <K extends Query.AnyKey, A, KE = never, KR = never, E = never, R = never, P = never>(
options: make.Options<K, A, KE, KR, E, R, P>
): Effect.Effect<
Query<K, A, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
Query<K, A, KE, KR, E, Result.forkEffect.OutputContext<A, E, R, P>, P>,
never,
Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext<A, E, R, P>
Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext<A, E, R, P>
> => Effect.tap(
make(options),
query => Effect.forkScoped(query.run),

View File

@@ -240,16 +240,16 @@ export const unsafeForkEffect = <A, E, R, P = never>(
Effect.provide(Layer.empty.pipe(
Layer.provideMerge(makeProgressLayer<A, E, P>()),
Layer.provideMerge(Layer.succeed(State<A, E, P>(), {
get: ref,
get: Ref.get(ref),
set: v => Effect.andThen(Ref.set(ref, v), PubSub.publish(pubsub, v))
})),
)),
))),
Effect.map(({ ref, pubsub, fiber }) => [
Subscribable.make({
get: ref,
get: Ref.get(ref),
changes: Stream.unwrapScoped(Effect.map(
Effect.all([ref, Stream.fromPubSub(pubsub, { scoped: true })]),
Effect.all([Ref.get(ref), Stream.fromPubSub(pubsub, { scoped: true })]),
([latest, stream]) => Stream.concat(Stream.make(latest), stream),
)),
}),

View File

@@ -18,7 +18,7 @@ extends Omit<TextField.RootProps, "optional" | "defaultValue">, Form.useOptional
export type TextFieldFormInputProps = Props | OptionalProps
export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(function*(props: TextFieldFormInputProps) {
export class TextFieldFormInputView extends Component.make("TextFieldFormInputView")(function*(props: TextFieldFormInputProps) {
const input: (
| { readonly optional: true } & Form.useOptionalInput.Success<string>
| { readonly optional: false } & Form.useInput.Success<string>

View File

@@ -13,10 +13,10 @@ import { Route as ResultRouteImport } from './routes/result'
import { Route as QueryRouteImport } from './routes/query'
import { Route as FormRouteImport } from './routes/form'
import { Route as BlankRouteImport } from './routes/blank'
import { Route as AsyncRouteImport } from './routes/async'
import { Route as IndexRouteImport } from './routes/index'
import { Route as DevMemoRouteImport } from './routes/dev/memo'
import { Route as DevContextRouteImport } from './routes/dev/context'
import { Route as DevAsyncRenderingRouteImport } from './routes/dev/async-rendering'
const ResultRoute = ResultRouteImport.update({
id: '/result',
@@ -38,6 +38,11 @@ const BlankRoute = BlankRouteImport.update({
path: '/blank',
getParentRoute: () => rootRouteImport,
} as any)
const AsyncRoute = AsyncRouteImport.update({
id: '/async',
path: '/async',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
@@ -53,40 +58,35 @@ const DevContextRoute = DevContextRouteImport.update({
path: '/dev/context',
getParentRoute: () => rootRouteImport,
} as any)
const DevAsyncRenderingRoute = DevAsyncRenderingRouteImport.update({
id: '/dev/async-rendering',
path: '/dev/async-rendering',
getParentRoute: () => rootRouteImport,
} as any)
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/async': typeof AsyncRoute
'/blank': typeof BlankRoute
'/form': typeof FormRoute
'/query': typeof QueryRoute
'/result': typeof ResultRoute
'/dev/async-rendering': typeof DevAsyncRenderingRoute
'/dev/context': typeof DevContextRoute
'/dev/memo': typeof DevMemoRoute
}
@@ -94,42 +94,42 @@ export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths:
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/async-rendering'
| '/dev/context'
| '/dev/memo'
fileRoutesByTo: FileRoutesByTo
to:
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/async-rendering'
| '/dev/context'
| '/dev/memo'
id:
| '__root__'
| '/'
| '/async'
| '/blank'
| '/form'
| '/query'
| '/result'
| '/dev/async-rendering'
| '/dev/context'
| '/dev/memo'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AsyncRoute: typeof AsyncRoute
BlankRoute: typeof BlankRoute
FormRoute: typeof FormRoute
QueryRoute: typeof QueryRoute
ResultRoute: typeof ResultRoute
DevAsyncRenderingRoute: typeof DevAsyncRenderingRoute
DevContextRoute: typeof DevContextRoute
DevMemoRoute: typeof DevMemoRoute
}
@@ -164,6 +164,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof BlankRouteImport
parentRoute: typeof rootRouteImport
}
'/async': {
id: '/async'
path: '/async'
fullPath: '/async'
preLoaderRoute: typeof AsyncRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -185,23 +192,16 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DevContextRouteImport
parentRoute: typeof rootRouteImport
}
'/dev/async-rendering': {
id: '/dev/async-rendering'
path: '/dev/async-rendering'
fullPath: '/dev/async-rendering'
preLoaderRoute: typeof DevAsyncRenderingRouteImport
parentRoute: typeof rootRouteImport
}
}
}
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AsyncRoute: AsyncRoute,
BlankRoute: BlankRoute,
FormRoute: FormRoute,
QueryRoute: QueryRoute,
ResultRoute: ResultRoute,
DevAsyncRenderingRoute: DevAsyncRenderingRoute,
DevContextRoute: DevContextRoute,
DevMemoRoute: DevMemoRoute,
}

View File

@@ -0,0 +1,71 @@
import { HttpClient } from "@effect/platform"
import { Container, Flex, Heading, Slider, Text, TextField } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Array, Effect, flow, Option, Schema } from "effect"
import { Async, Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
const Post = Schema.Struct({
userId: Schema.Int,
id: Schema.Int,
title: Schema.String,
body: Schema.String,
})
interface AsyncFetchPostViewProps {
readonly id: number
}
class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*(props: AsyncFetchPostViewProps) {
const post = yield* Component.useOnChange(() => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ props.id }`)),
Effect.andThen(response => response.json),
Effect.andThen(Schema.decodeUnknown(Post)),
), [props.id])
return (
<div>
<Heading>{post.title}</Heading>
<Text>{post.body}</Text>
</div>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <Text>Default fallback</Text> }),
Memoized.memoized,
) {}
const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() {
const [text, setText] = React.useState("Typing here should not trigger a refetch of the post")
const [id, setId] = React.useState(1)
const AsyncFetchPost = yield* AsyncFetchPostView.use
return (
<Container>
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={text}
onChange={e => setText(e.currentTarget.value)}
/>
<Slider
value={[id]}
onValueChange={flow(Array.head, Option.getOrThrow, setId)}
/>
<AsyncFetchPost id={id} fallback={<Text>Loading post...</Text>} />
</Flex>
</Container>
)
}).pipe(
Component.withRuntime(runtime.context)
)
export const Route = createFileRoute("/async")({
component: AsyncRouteComponent,
})

View File

@@ -1,78 +0,0 @@
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 { Async, Component, Memoized } from "effect-fc"
import * as React from "react"
import { runtime } from "@/runtime"
// Generator version
const RouteComponent = Component.makeUntraced(function* AsyncRendering() {
const MemoizedAsyncComponentFC = yield* MemoizedAsyncComponent.use
const AsyncComponentFC = yield* AsyncComponent.use
const [input, setInput] = React.useState("")
return (
<Flex direction="column" align="stretch" gap="2">
<TextField.Root
value={input}
onChange={e => setInput(e.target.value)}
/>
<MemoizedAsyncComponentFC fallback={React.useMemo(() => <p>Loading memoized...</p>, [])} />
<AsyncComponentFC />
</Flex>
)
}).pipe(
Component.withRuntime(runtime.context)
)
// Pipeline version
// const RouteComponent = Component.make("RouteComponent")(() => Effect.Do,
// Effect.bind("VMemoizedAsyncComponent", () => Component.useFC(MemoizedAsyncComponent)),
// Effect.bind("VAsyncComponent", () => Component.useFC(AsyncComponent)),
// Effect.let("input", () => React.useState("")),
// Effect.map(({ input: [input, setInput], VAsyncComponent, VMemoizedAsyncComponent }) =>
// <Flex direction="column" align="stretch" gap="2">
// <TextField.Root
// value={input}
// onChange={e => setInput(e.target.value)}
// />
// <VMemoizedAsyncComponent />
// <VAsyncComponent />
// </Flex>
// ),
// ).pipe(
// Component.withRuntime(runtime.context)
// )
class AsyncComponent extends Component.makeUntraced("AsyncComponent")(function*() {
const SubComponentFC = yield* SubComponent.use
yield* Effect.sleep("500 millis") // Async operation
// Cannot use React hooks after the async operation
return (
<Flex direction="column" align="stretch">
<Text>Rendered!</Text>
<SubComponentFC />
</Flex>
)
}).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading...</p> }),
) {}
class MemoizedAsyncComponent extends Memoized.memoized(AsyncComponent) {}
class SubComponent extends Component.makeUntraced("SubComponent")(function*() {
const [state] = React.useState(yield* Component.useOnMount(() => Effect.provide(makeUuid4, GetRandomValues.CryptoRandom)))
return <Text>{state}</Text>
}) {}
export const Route = createFileRoute("/dev/async-rendering")({
component: RouteComponent
})

View File

@@ -23,7 +23,7 @@ const SubComponent = Component.makeUntraced("SubComponent")(function*() {
const ContextView = Component.makeUntraced("ContextView")(function*() {
const [serviceValue, setServiceValue] = React.useState("test")
const SubServiceLayer = React.useMemo(() => SubService.Default(serviceValue), [serviceValue])
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContext(SubServiceLayer))
const SubComponentFC = yield* Effect.provide(SubComponent.use, yield* Component.useContextFromLayer(SubServiceLayer))
return (
<Container>

View File

@@ -2,7 +2,7 @@ import { Button, Container, Flex, Text } from "@radix-ui/themes"
import { createFileRoute } from "@tanstack/react-router"
import { Console, Effect, Match, Option, ParseResult, Schema } from "effect"
import { Component, Form, Subscribable } from "effect-fc"
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { runtime } from "@/runtime"
@@ -38,7 +38,7 @@ const RegisterFormSubmitSchema = Schema.Struct({
birth: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
})
class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
class RegisterFormService extends Effect.Service<RegisterFormService>()("RegisterFormService", {
scoped: Form.service({
schema: RegisterFormSchema.pipe(
Schema.compose(
@@ -62,15 +62,15 @@ class RegisterForm extends Effect.Service<RegisterForm>()("RegisterForm", {
})
}) {}
class RegisterFormView extends Component.makeUntraced("RegisterFormView")(function*() {
const form = yield* RegisterForm
class RegisterFormView extends Component.make("RegisterFormView")(function*() {
const form = yield* RegisterFormService
const [canSubmit, submitResult] = yield* Subscribable.useSubscribables([
form.canSubmit,
form.mutation.result,
])
const runPromise = yield* Component.useRunPromise()
const TextFieldFormInputFC = yield* TextFieldFormInput.use
const TextFieldFormInput = yield* TextFieldFormInputView.use
yield* Component.useOnMount(() => Effect.gen(function*() {
yield* Effect.addFinalizer(() => Console.log("RegisterFormView unmounted"))
@@ -85,15 +85,15 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi
void runPromise(form.submit)
}}>
<Flex direction="column" gap="2">
<TextFieldFormInputFC
<TextFieldFormInput
field={yield* form.field(["email"])}
/>
<TextFieldFormInputFC
<TextFieldFormInput
field={yield* form.field(["password"])}
/>
<TextFieldFormInputFC
<TextFieldFormInput
optional
type="datetime-local"
field={yield* form.field(["birth"])}
@@ -115,13 +115,13 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi
)
}) {}
const RegisterPage = Component.makeUntraced("RegisterPage")(function*() {
const RegisterFormViewFC = yield* Effect.provide(
const RegisterPage = Component.make("RegisterPageView")(function*() {
const RegisterForm = yield* Effect.provide(
RegisterFormView.use,
yield* Component.useContext(RegisterForm.Default),
yield* Component.useContextFromLayer(RegisterFormService.Default),
)
return <RegisterFormViewFC />
return <RegisterForm />
}).pipe(
Component.withRuntime(runtime.context)
)

View File

@@ -2,19 +2,19 @@ import { createFileRoute } from "@tanstack/react-router"
import { Effect } from "effect"
import { Component } from "effect-fc"
import { runtime } from "@/runtime"
import { Todos } from "@/todo/Todos"
import { TodosState } from "@/todo/TodosState.service"
import { TodosState } from "@/todo/TodosState"
import { TodosView } from "@/todo/TodosView"
const TodosStateLive = TodosState.Default("todos")
const Index = Component.makeUntraced("Index")(function*() {
const TodosFC = yield* Effect.provide(
Todos.use,
yield* Component.useContext(TodosStateLive),
const Index = Component.make("IndexView")(function*() {
const Todos = yield* Effect.provide(
TodosView.use,
yield* Component.useContextFromLayer(TodosStateLive),
)
return <TodosFC />
return <Todos />
}).pipe(
Component.withRuntime(runtime.context)
)

View File

@@ -13,15 +13,16 @@ const Post = Schema.Struct({
body: Schema.String,
})
const ResultView = Component.makeUntraced("Result")(function*() {
const ResultView = Component.make("ResultView")(function*() {
const runPromise = yield* Component.useRunPromise()
const [idRef, query, mutation] = yield* Component.useOnMount(() => Effect.gen(function*() {
const idRef = yield* SubscriptionRef.make(1)
const key = Stream.map(idRef.changes, id => [id] as const)
const query = yield* Query.service({
key: Stream.zipLatest(Stream.make("posts" as const), idRef.changes),
f: ([, id]) => HttpClient.HttpClient.pipe(
key,
f: ([id]) => HttpClient.HttpClient.pipe(
Effect.tap(Effect.sleep("500 millis")),
Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)),
Effect.andThen(response => response.json),

View File

@@ -5,9 +5,9 @@ import { Component, Form, Subscribable } from "effect-fc"
import { FaArrowDown, FaArrowUp } from "react-icons/fa"
import { FaDeleteLeft } from "react-icons/fa6"
import * as Domain from "@/domain"
import { TextFieldFormInput } from "@/lib/form/TextFieldFormInput"
import { TextFieldFormInputView } from "@/lib/form/TextFieldFormInputView"
import { DateTimeUtcFromZonedInput } from "@/lib/schema"
import { TodosState } from "./TodosState.service"
import { TodosState } from "./TodosState"
const TodoFormSchema = Schema.compose(Schema.Struct({
@@ -30,7 +30,7 @@ export type TodoProps = (
| { readonly _tag: "edit", readonly id: string }
)
export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoProps) {
export class TodoView extends Component.make("TodoView")(function*(props: TodoProps) {
const state = yield* TodosState
const [
@@ -83,17 +83,17 @@ export class Todo extends Component.makeUntraced("Todo")(function*(props: TodoPr
const runSync = yield* Component.useRunSync()
const runPromise = yield* Component.useRunPromise<DateTime.CurrentTimeZone>()
const TextFieldFormInputFC = yield* TextFieldFormInput.use
const TextFieldFormInput = yield* TextFieldFormInputView.use
return (
<Flex direction="row" align="center" gap="2">
<Box flexGrow="1">
<Flex direction="column" align="stretch" gap="2">
<TextFieldFormInputFC field={contentField} />
<TextFieldFormInput field={contentField} />
<Flex direction="row" justify="center" align="center" gap="2">
<TextFieldFormInputFC
<TextFieldFormInput
optional
field={completedAtField}
type="datetime-local"

View File

@@ -1,11 +1,11 @@
import { Container, Flex, Heading } from "@radix-ui/themes"
import { Chunk, Console, Effect } from "effect"
import { Component, Subscribable } from "effect-fc"
import { Todo } from "./Todo"
import { TodosState } from "./TodosState.service"
import { TodosState } from "./TodosState"
import { TodoView } from "./TodoView"
export class Todos extends Component.makeUntraced("Todos")(function*() {
export class TodosView extends Component.make("TodosView")(function*() {
const state = yield* TodosState
const [todos] = yield* Subscribable.useSubscribables([state.ref])
@@ -14,17 +14,17 @@ export class Todos extends Component.makeUntraced("Todos")(function*() {
Effect.addFinalizer(() => Console.log("Todos unmounted")),
))
const TodoFC = yield* Todo.use
const Todo = yield* TodoView.use
return (
<Container>
<Heading align="center">Todos</Heading>
<Flex direction="column" align="stretch" gap="2" mt="2">
<TodoFC _tag="new" />
<Todo _tag="new" />
{Chunk.map(todos, todo =>
<TodoFC key={todo.id} _tag="edit" id={todo.id} />
<Todo key={todo.id} _tag="edit" id={todo.id} />
)}
</Flex>
</Container>