From 6c14495693424cde1869603af3ab883a8012e1fc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 25 Feb 2026 22:33:18 +0100 Subject: [PATCH 01/31] Improve comments --- packages/effect-fc/src/Component.ts | 433 ++++++++++++++++++++++++---- 1 file changed, 373 insertions(+), 60 deletions(-) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 8a21b5b..19e9726 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -8,11 +8,7 @@ export const TypeId: unique symbol = Symbol.for("@effect-fc/Component/Component" export type TypeId = typeof TypeId /** - * 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

extends ComponentPrototype, ComponentOptions { @@ -98,18 +94,25 @@ const use = Effect.fnUntraced(function*

{value} + * }) + * ``` + * + * @example As an opaque type using class syntax + * ```tsx + * class MyComponent extends Component.make("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return

{value}
+ * }) + * ``` + * + * @example Without name + * ```tsx + * const MyComponent = Component.make((props: { count: number }) => { + * return Effect.sync(() =>
{props.count}
) + * }) + * ``` */ export const make: ( & make.Gen @@ -393,15 +421,33 @@ 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 string is provided as the first argument, it is used **exclusively** as the React component's + * `displayName` for DevTools identification. No tracing span is created. + * + * @example + * ```tsx + * const MyComponent = Component.makeUntraced(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) + * ``` + * + * @example With displayName only + * ```tsx + * const MyComponent = Component.makeUntraced("MyComponent", (props: { count: number }) => { + * return Effect.sync(() =>
{props.count}
) + * }) + * ``` */ export const makeUntraced: ( & make.Gen @@ -425,7 +471,21 @@ 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 MyComponent = Component.make(...) + * const MyComponentWithCustomOptions = MyComponent.pipe( + * Component.withOptions({ + * finalizerExecutionStrategy: ExecutionStrategy.parallel, + * finalizerExecutionDebounce: "50 millis" + * }) + * ) + * ``` */ export const withOptions: { >( @@ -444,21 +504,30 @@ 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 the recommended approach for integrating Effect-FC components 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, enabling: + * - Single-point dependency injection at the application root + * - Consistent runtime environment across entire route trees or feature modules + * - Proper resource lifecycle management across component hierarchies * - * @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 ( * @@ -467,14 +536,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: {

( @@ -496,8 +563,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()("@effect-fc/Component/ScopeMap", { effect: Effect.bind(Effect.Do, "ref", () => Ref.make(HashMap.empty())) @@ -519,13 +588,22 @@ 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, options?: useScope.Options, @@ -579,7 +657,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* (props) { + * const initialData = yield* useOnMount(() => fetchData) + * return

{initialData}
+ * }) + * ``` */ export const useOnMount = Effect.fnUntraced(function* ( f: () => Effect.Effect @@ -593,9 +687,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* useOnChange( + * fetchUser(props.userId), + * [props.userId], + * ) + * return
{userData.name}
+ * }) + * ``` */ export const useOnChange = Effect.fnUntraced(function* ( f: () => Effect.Effect, @@ -619,9 +737,35 @@ 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 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* useReactEffect( + * () => { + * const subscription = subscribe(props.id) + * return Effect.addFinalizer(() => subscription.unsubscribe()) + * }, + * [props.id] + * ) + * return
Subscribed to {props.id}
+ * }) + * ``` */ export const useReactEffect = Effect.fnUntraced(function* ( f: () => Effect.Effect, @@ -660,9 +804,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* (props) { + * const ref = React.useRef(null) + * yield* useReactLayoutEffect( + * () => { + * if (ref.current) { + * const height = ref.current.offsetHeight + * // Perform layout-dependent operations + * } + * return Effect.void + * }, + * [] + * ) + * return
Content
+ * }) + * ``` */ export const useReactLayoutEffect = Effect.fnUntraced(function* ( f: () => Effect.Effect, @@ -675,7 +853,25 @@ export const useReactLayoutEffect = Effect.fnUntraced(function* ( }) /** - * 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* (props) { + * const runSync = yield* useRunSync() + * const handleClick = () => { + * const result = runSync(someEffect) + * console.log(result) + * } + * return + * }) + * ``` */ export const useRunSync = (): Effect.Effect< (effect: Effect.Effect) => A, @@ -684,7 +880,29 @@ export const useRunSync = (): 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* (props) { + * const runPromise = yield* useRunPromise() + * const handleClick = async () => { + * try { + * const result = await runPromise(someEffect) + * console.log(result) + * } catch (error) { + * console.error(error) + * } + * } + * return + * }) + * ``` */ export const useRunPromise = (): Effect.Effect< (effect: Effect.Effect) => Promise, @@ -693,7 +911,31 @@ export const useRunPromise = (): 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* useCallbackSync( + * (data: Data) => saveData(data), + * [props.onSave] + * ) + * return + * }) + * ``` */ export const useCallbackSync = Effect.fnUntraced(function* ( f: (...args: Args) => Effect.Effect, @@ -708,7 +950,41 @@ export const useCallbackSync = Effect.fnUntraced(function* void }) { + * const handleSave = yield* useCallbackPromise( + * (data: Data) => saveData(data), + * [props.onSave] + * ) + * return ( + * + * ) + * }) + * ``` */ export const useCallbackPromise = Effect.fnUntraced(function* ( f: (...args: Args) => Effect.Effect, @@ -727,11 +1003,48 @@ 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 MyComponent = Component.make(function* (props) { + * const context = yield* useContext( + * Layer.succeed(MyService, new MyServiceImpl()) + * ) + * // Use context to access services + * return
Using services
+ * }) + * ``` + * + * @example With async layer + * ```tsx + * const MyComponent = Component.make(function* (props) { + * const context = yield* useContext( + * Layer.effect(MyService, () => fetchServiceConfig()) + * ) + * return
Using async services
+ * }) + * + * // Must be wrapped with Async.async + * export default Async.async(MyComponent) + * ``` */ export const useContext = ( layer: Layer.Layer, From 2c78b17f529723b0c2d81cb9dd8827629a065785 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Feb 2026 10:16:52 +0100 Subject: [PATCH 02/31] Update comments --- packages/effect-fc/src/Component.ts | 198 ++++++++++++++++------------ 1 file changed, 111 insertions(+), 87 deletions(-) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 19e9726..28f0091 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -365,7 +365,7 @@ export declare namespace make { * - **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 as the first argument, the following occurs automatically: + * 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 * @@ -382,14 +382,23 @@ export declare namespace make { * class MyComponent extends Component.make("MyComponent")(function* (props: { count: number }) { * const value = yield* someEffect * return
{value}
- * }) + * }) {} * ``` * * @example Without name * ```tsx - * const MyComponent = Component.make((props: { count: number }) => { - * return Effect.sync(() =>
{props.count}
) - * }) + * class MyComponent extends Component.make(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Using pipeline + * ```tsx + * class MyComponent extends Component.make("MyComponent")( + * (props: { count: number }) => someEffect, + * Effect.map(value =>
{value}
), + * ) {} * ``` */ export const make: ( @@ -431,22 +440,39 @@ export const make: ( * - To reduce tracing overhead in deeply nested component hierarchies * - To avoid span noise in performance-sensitive applications * - * When a string is provided as the first argument, it is used **exclusively** as the React component's + * 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(function* (props: { count: number }) { + * const MyComponent = Component.makeUntraced("MyComponent")(function* (props: { count: number }) { * const value = yield* someEffect * return
{value}
* }) * ``` * - * @example With displayName only + * @example As an opaque type using class syntax * ```tsx - * const MyComponent = Component.makeUntraced("MyComponent", (props: { count: number }) => { - * return Effect.sync(() =>
{props.count}
) - * }) + * class MyComponent extends Component.makeUntraced("MyComponent")(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Without name + * ```tsx + * class MyComponent extends Component.makeUntraced(function* (props: { count: number }) { + * const value = yield* someEffect + * return
{value}
+ * }) {} + * ``` + * + * @example Using pipeline + * ```tsx + * class MyComponent extends Component.makeUntraced("MyComponent")( + * (props: { count: number }) => someEffect, + * Effect.map(value =>
{value}
), + * ) {} * ``` */ export const makeUntraced: ( @@ -478,7 +504,6 @@ export const makeUntraced: ( * * @example * ```tsx - * const MyComponent = Component.make(...) * const MyComponentWithCustomOptions = MyComponent.pipe( * Component.withOptions({ * finalizerExecutionStrategy: ExecutionStrategy.parallel, @@ -507,17 +532,14 @@ export const withOptions: { * 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 approach for integrating Effect-FC components with the broader React ecosystem, + * 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 Effect runtime is obtained from the provided React Context, enabling: - * - Single-point dependency injection at the application root - * - Consistent runtime environment across entire route trees or feature modules - * - Proper resource lifecycle management across component hierarchies + * The Effect runtime is obtained from the provided React Context. * * @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. @@ -669,8 +691,8 @@ export const useScope = Effect.fnUntraced(function*( * * @example * ```tsx - * const MyComponent = Component.make(function* (props) { - * const initialData = yield* useOnMount(() => fetchData) + * const MyComponent = Component.make(function*() { + * const initialData = yield* Component.useOnMount(() => getData) * return
{initialData}
* }) * ``` @@ -707,8 +729,8 @@ export declare namespace useOnChange { * @example * ```tsx * const MyComponent = Component.make(function* (props: { userId: string }) { - * const userData = yield* useOnChange( - * fetchUser(props.userId), + * const userData = yield* Component.useOnChange( + * getUser(props.userId), * [props.userId], * ) * return
{userData.name}
@@ -740,7 +762,7 @@ export declare namespace useReactEffect { * Effect hook that provides Effect-based semantics for React.useEffect. * * This hook bridges React's useEffect with the Effect system, allowing you to use Effects - * for side effects while maintaining React's dependency tracking and lifecycle semantics. + * 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 @@ -756,14 +778,15 @@ export declare namespace useReactEffect { * @example * ```tsx * const MyComponent = Component.make(function* (props: { id: string }) { - * yield* useReactEffect( - * () => { - * const subscription = subscribe(props.id) - * return Effect.addFinalizer(() => subscription.unsubscribe()) - * }, - * [props.id] + * yield* Component.useReactEffect( + * () => getNotificationStreamForUser(props.id).pipe( + * Stream.unwrap, + * Stream.runForEach(notification => Console.log(`Notification received: ${ notification }`), + * Effect.forkScoped, + * ), + * [props.id], * ) - * return
Subscribed to {props.id}
+ * return
Subscribed to notifications for {props.id}
* }) * ``` */ @@ -826,17 +849,17 @@ export declare namespace useReactLayoutEffect { * * @example * ```tsx - * const MyComponent = Component.make(function* (props) { + * const MyComponent = Component.make(function*() { * const ref = React.useRef(null) - * yield* useReactLayoutEffect( - * () => { - * if (ref.current) { - * const height = ref.current.offsetHeight - * // Perform layout-dependent operations + * 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 Effect.void - * }, - * [] + * }), + * [], * ) * return
Content
* }) @@ -863,13 +886,11 @@ export const useReactLayoutEffect = Effect.fnUntraced(function* ( * * @example * ```tsx - * const MyComponent = Component.make(function* (props) { - * const runSync = yield* useRunSync() - * const handleClick = () => { - * const result = runSync(someEffect) - * console.log(result) - * } - * return + * const MyComponent = Component.make(function*() { + * const runSync = yield* Component.useRunSync() // Specify required services + * const runSync = yield* Component.useRunSync() // Or no service requirements + * + * return * }) * ``` */ @@ -890,17 +911,11 @@ export const useRunSync = (): Effect.Effect< * * @example * ```tsx - * const MyComponent = Component.make(function* (props) { - * const runPromise = yield* useRunPromise() - * const handleClick = async () => { - * try { - * const result = await runPromise(someEffect) - * console.log(result) - * } catch (error) { - * console.error(error) - * } - * } - * return + * const MyComponent = Component.make(function*() { + * const runPromise = yield* Component.useRunPromise() // Specify required services + * const runPromise = yield* Component.useRunPromise() // Or no service requirements + * + * return * }) * ``` */ @@ -929,10 +944,11 @@ export const useRunPromise = (): Effect.Effect< * @example * ```tsx * const MyComponent = Component.make(function* (props: { onSave: (data: Data) => void }) { - * const handleSave = yield* useCallbackSync( - * (data: Data) => saveData(data), - * [props.onSave] + * const handleSave = yield* Component.useCallbackSync( + * (data: Data) => Effect.sync(() => props.onSave(data)), + * [props.onSave], * ) + * * return * }) * ``` @@ -968,21 +984,12 @@ export const useCallbackSync = Effect.fnUntraced(function* void }) { - * const handleSave = yield* useCallbackPromise( - * (data: Data) => saveData(data), - * [props.onSave] - * ) - * return ( - * + * const handleSave = yield* Component.useCallbackPromise( + * (data: Data) => Effect.promise(() => props.onSave(data)), + * [props.onSave], * ) + * + * return * }) * ``` */ @@ -1024,32 +1031,49 @@ export declare namespace useContext { * * @example * ```tsx - * const MyComponent = Component.make(function* (props) { - * const context = yield* useContext( - * Layer.succeed(MyService, new MyServiceImpl()) + * const MyLayer = Layer.succeed(MyService, new MyServiceImpl()) + * const MyComponent = Component.make(function*() { + * const context = yield* Component.useContext(MyLayer) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) * ) - * // Use context to access services - * return
Using services
+ * + * return + * }) + * ``` + * + * @example With memoized layer + * ```tsx + * const MyComponent = Component.make(function*(props: { id: string })) { + * const context = yield* Component.useContext( + * React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id]) + * ) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) + * ) + * + * return * }) * ``` * * @example With async layer * ```tsx - * const MyComponent = Component.make(function* (props) { - * const context = yield* useContext( - * Layer.effect(MyService, () => fetchServiceConfig()) + * const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect) + * const MyComponent = Component.make(function*() { + * const context = yield* Component.useContext(MyAsyncLayer) + * const Sub = yield* SubComponent.use.pipe( + * Effect.provide(context) * ) - * return
Using async services
- * }) * - * // Must be wrapped with Async.async - * export default Async.async(MyComponent) - * ``` + * return + * }).pipe( + * Async.async // Required to handle async layer effects + * ) */ export const useContext = ( layer: Layer.Layer, options?: useContext.Options, -): Effect.Effect, E, Exclude> => useOnChange(() => Effect.context().pipe( +): Effect.Effect, E, RIn | Scope.Scope> => useOnChange(() => Effect.context().pipe( Effect.map(context => ManagedRuntime.make(Layer.provide(layer, Layer.succeedContext(context)))), Effect.tap(runtime => Effect.addFinalizer(() => runtime.disposeEffect)), Effect.andThen(runtime => runtime.runtimeEffect), From 97246845daf8b16e232582c38331903a353cedd1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Feb 2026 10:45:08 +0100 Subject: [PATCH 03/31] useContext -> useContextFromLayer --- packages/effect-fc/src/Component.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 28f0091..0398dac 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -1033,7 +1033,7 @@ export declare namespace useContext { * ```tsx * const MyLayer = Layer.succeed(MyService, new MyServiceImpl()) * const MyComponent = Component.make(function*() { - * const context = yield* Component.useContext(MyLayer) + * const context = yield* Component.useContextFromLayer(MyLayer) * const Sub = yield* SubComponent.use.pipe( * Effect.provide(context) * ) @@ -1045,7 +1045,7 @@ export declare namespace useContext { * @example With memoized layer * ```tsx * const MyComponent = Component.make(function*(props: { id: string })) { - * const context = yield* Component.useContext( + * const context = yield* Component.useContextFromLayer( * React.useMemo(() => Layer.succeed(MyService, new MyServiceImpl(props.id)), [props.id]) * ) * const Sub = yield* SubComponent.use.pipe( @@ -1060,7 +1060,7 @@ export declare namespace useContext { * ```tsx * const MyAsyncLayer = Layer.effect(MyService, someAsyncEffect) * const MyComponent = Component.make(function*() { - * const context = yield* Component.useContext(MyAsyncLayer) + * const context = yield* Component.useContextFromLayer(MyAsyncLayer) * const Sub = yield* SubComponent.use.pipe( * Effect.provide(context) * ) @@ -1070,7 +1070,7 @@ export declare namespace useContext { * Async.async // Required to handle async layer effects * ) */ -export const useContext = ( +export const useContextFromLayer = ( layer: Layer.Layer, options?: useContext.Options, ): Effect.Effect, E, RIn | Scope.Scope> => useOnChange(() => Effect.context().pipe( From 8e81ec85deed3f72d314a49e5514ce9ec13c2c3c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Feb 2026 10:49:54 +0100 Subject: [PATCH 04/31] Fix --- packages/example/src/routes/dev/context.tsx | 2 +- packages/example/src/routes/form.tsx | 2 +- packages/example/src/routes/index.tsx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/example/src/routes/dev/context.tsx b/packages/example/src/routes/dev/context.tsx index b1212c5..26048a9 100644 --- a/packages/example/src/routes/dev/context.tsx +++ b/packages/example/src/routes/dev/context.tsx @@ -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 ( diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index 3060dca..f9d8916 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -118,7 +118,7 @@ class RegisterFormView extends Component.makeUntraced("RegisterFormView")(functi const RegisterPage = Component.makeUntraced("RegisterPage")(function*() { const RegisterFormViewFC = yield* Effect.provide( RegisterFormView.use, - yield* Component.useContext(RegisterForm.Default), + yield* Component.useContextFromLayer(RegisterForm.Default), ) return diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx index cd9ffa8..053474a 100644 --- a/packages/example/src/routes/index.tsx +++ b/packages/example/src/routes/index.tsx @@ -11,7 +11,7 @@ const TodosStateLive = TodosState.Default("todos") const Index = Component.makeUntraced("Index")(function*() { const TodosFC = yield* Effect.provide( Todos.use, - yield* Component.useContext(TodosStateLive), + yield* Component.useContextFromLayer(TodosStateLive), ) return From 21028fd75bb98c5eb9025c4d5dee8331d3b458cf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Feb 2026 13:21:56 +0100 Subject: [PATCH 05/31] Refactor --- ...rmInput.tsx => TextFieldFormInputView.tsx} | 2 +- packages/example/src/routes/form.tsx | 24 +++++++++---------- packages/example/src/routes/index.tsx | 12 +++++----- packages/example/src/routes/query.tsx | 6 ++--- .../src/todo/{Todo.tsx => TodoView.tsx} | 12 +++++----- .../{TodosState.service.ts => TodosState.ts} | 0 .../src/todo/{Todos.tsx => TodosView.tsx} | 12 +++++----- 7 files changed, 34 insertions(+), 34 deletions(-) rename packages/example/src/lib/form/{TextFieldFormInput.tsx => TextFieldFormInputView.tsx} (95%) rename packages/example/src/todo/{Todo.tsx => TodoView.tsx} (92%) rename packages/example/src/todo/{TodosState.service.ts => TodosState.ts} (100%) rename packages/example/src/todo/{Todos.tsx => TodosView.tsx} (71%) diff --git a/packages/example/src/lib/form/TextFieldFormInput.tsx b/packages/example/src/lib/form/TextFieldFormInputView.tsx similarity index 95% rename from packages/example/src/lib/form/TextFieldFormInput.tsx rename to packages/example/src/lib/form/TextFieldFormInputView.tsx index b3ba633..664402f 100644 --- a/packages/example/src/lib/form/TextFieldFormInput.tsx +++ b/packages/example/src/lib/form/TextFieldFormInputView.tsx @@ -18,7 +18,7 @@ extends Omit, Form.useOptional export type TextFieldFormInputProps = Props | OptionalProps -export class TextFieldFormInput extends Component.makeUntraced("TextFieldFormInput")(function*(props: TextFieldFormInputProps) { +export class TextFieldFormInputView extends Component.make("TextFieldFormInput")(function*(props: TextFieldFormInputProps) { const input: ( | { readonly optional: true } & Form.useOptionalInput.Success | { readonly optional: false } & Form.useInput.Success diff --git a/packages/example/src/routes/form.tsx b/packages/example/src/routes/form.tsx index f9d8916..d8ba6d9 100644 --- a/packages/example/src/routes/form.tsx +++ b/packages/example/src/routes/form.tsx @@ -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", { +class RegisterFormService extends Effect.Service()("RegisterFormService", { scoped: Form.service({ schema: RegisterFormSchema.pipe( Schema.compose( @@ -62,15 +62,15 @@ class RegisterForm extends Effect.Service()("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) }}> - - - + return }).pipe( Component.withRuntime(runtime.context) ) diff --git a/packages/example/src/routes/index.tsx b/packages/example/src/routes/index.tsx index 053474a..d747b39 100644 --- a/packages/example/src/routes/index.tsx +++ b/packages/example/src/routes/index.tsx @@ -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, +const Index = Component.make("IndexView")(function*() { + const Todos = yield* Effect.provide( + TodosView.use, yield* Component.useContextFromLayer(TodosStateLive), ) - return + return }).pipe( Component.withRuntime(runtime.context) ) diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index 2092c0c..1db5ef5 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -13,15 +13,15 @@ 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 query = yield* Query.service({ - key: Stream.zipLatest(Stream.make("posts" as const), idRef.changes), - f: ([, id]) => HttpClient.HttpClient.pipe( + key: Stream.map(idRef.changes, id => [id] as const), + 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), diff --git a/packages/example/src/todo/Todo.tsx b/packages/example/src/todo/TodoView.tsx similarity index 92% rename from packages/example/src/todo/Todo.tsx rename to packages/example/src/todo/TodoView.tsx index a26a511..4086c5b 100644 --- a/packages/example/src/todo/Todo.tsx +++ b/packages/example/src/todo/TodoView.tsx @@ -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() - const TextFieldFormInputFC = yield* TextFieldFormInput.use + const TextFieldFormInput = yield* TextFieldFormInputView.use return ( - + - Console.log("Todos unmounted")), )) - const TodoFC = yield* Todo.use + const Todo = yield* TodoView.use return ( Todos - + {Chunk.map(todos, todo => - + )} From 2766e86f5d40e5a47d7ccea2e8ec5881d4c6bb73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Feb 2026 14:14:54 +0100 Subject: [PATCH 06/31] Fix --- packages/effect-fc/src/Query.ts | 2 +- packages/example/src/routes/query.tsx | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/effect-fc/src/Query.ts b/packages/effect-fc/src/Query.ts index 9c306b7..875cb84 100644 --- a/packages/effect-fc/src/Query.ts +++ b/packages/effect-fc/src/Query.ts @@ -11,7 +11,7 @@ extends Pipeable.Pipeable { readonly [QueryTypeId]: QueryTypeId readonly context: Context.Context - readonly key: Stream.Stream + readonly key: Stream.Stream readonly f: (key: K) => Effect.Effect readonly initialProgress: P diff --git a/packages/example/src/routes/query.tsx b/packages/example/src/routes/query.tsx index 1db5ef5..a6e2f4d 100644 --- a/packages/example/src/routes/query.tsx +++ b/packages/example/src/routes/query.tsx @@ -18,9 +18,10 @@ const ResultView = Component.make("ResultView")(function*() { 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.map(idRef.changes, id => [id] as const), + key, f: ([id]) => HttpClient.HttpClient.pipe( Effect.tap(Effect.sleep("500 millis")), Effect.andThen(client => client.get(`https://jsonplaceholder.typicode.com/posts/${ id }`)), From b3fe4a094689325e3d702052307b33b0088ee168 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Feb 2026 14:44:54 +0100 Subject: [PATCH 07/31] Fix --- packages/effect-fc/src/Query.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect-fc/src/Query.ts b/packages/effect-fc/src/Query.ts index 875cb84..9c306b7 100644 --- a/packages/effect-fc/src/Query.ts +++ b/packages/effect-fc/src/Query.ts @@ -11,7 +11,7 @@ extends Pipeable.Pipeable { readonly [QueryTypeId]: QueryTypeId readonly context: Context.Context - readonly key: Stream.Stream + readonly key: Stream.Stream readonly f: (key: K) => Effect.Effect readonly initialProgress: P From f8a1220f29c4988f9c3006e2f3fec6fb857b1aef Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Fri, 27 Feb 2026 14:53:50 +0100 Subject: [PATCH 08/31] Fix --- packages/example/src/lib/form/TextFieldFormInputView.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/example/src/lib/form/TextFieldFormInputView.tsx b/packages/example/src/lib/form/TextFieldFormInputView.tsx index 664402f..052348c 100644 --- a/packages/example/src/lib/form/TextFieldFormInputView.tsx +++ b/packages/example/src/lib/form/TextFieldFormInputView.tsx @@ -18,7 +18,7 @@ extends Omit, Form.useOptional export type TextFieldFormInputProps = Props | OptionalProps -export class TextFieldFormInputView extends Component.make("TextFieldFormInput")(function*(props: TextFieldFormInputProps) { +export class TextFieldFormInputView extends Component.make("TextFieldFormInputView")(function*(props: TextFieldFormInputProps) { const input: ( | { readonly optional: true } & Form.useOptionalInput.Success | { readonly optional: false } & Form.useInput.Success From 6a6733dc8a72c894d12a6f48d77819c41d351dc9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sat, 28 Feb 2026 03:07:10 +0100 Subject: [PATCH 09/31] Refactor Query --- packages/effect-fc/src/Query.ts | 37 +++++++++++++++++---------------- 1 file changed, 19 insertions(+), 18 deletions(-) diff --git a/packages/effect-fc/src/Query.ts b/packages/effect-fc/src/Query.ts index 9c306b7..6db26ea 100644 --- a/packages/effect-fc/src/Query.ts +++ b/packages/effect-fc/src/Query.ts @@ -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 +export interface Query extends Pipeable.Pipeable { readonly [QueryTypeId]: QueryTypeId readonly context: Context.Context - readonly key: Stream.Stream + readonly key: Stream.Stream readonly f: (key: K) => Effect.Effect readonly initialProgress: P @@ -37,13 +37,13 @@ export declare namespace Query { export type AnyKey = readonly any[] } -export class QueryImpl -extends Pipeable.Class() implements Query { +export class QueryImpl +extends Pipeable.Class() implements Query { readonly [QueryTypeId]: QueryTypeId = QueryTypeId constructor( - readonly context: Context.Context, - readonly key: Stream.Stream, + readonly context: Context.Context, + readonly key: Stream.Stream, readonly f: (key: K) => Effect.Effect, readonly initialProgress: P, @@ -77,6 +77,7 @@ extends Pipeable.Class() implements Query { ], { concurrency: "unbounded" }).pipe( Effect.ignore, this.runSemaphore.withPermits(1), + Effect.provide(this.context), ) } @@ -265,11 +266,11 @@ extends Pipeable.Class() implements Query { } } -export const isQuery = (u: unknown): u is Query => Predicate.hasProperty(u, QueryTypeId) +export const isQuery = (u: unknown): u is Query => Predicate.hasProperty(u, QueryTypeId) export declare namespace make { - export interface Options { - readonly key: Stream.Stream + export interface Options { + readonly key: Stream.Stream readonly f: (key: NoInfer) => Effect.Effect>> readonly initialProgress?: P readonly staleTime?: Duration.DurationInput @@ -277,17 +278,17 @@ export declare namespace make { } } -export const make = Effect.fnUntraced(function* ( - options: make.Options +export const make = Effect.fnUntraced(function* ( + options: make.Options ): Effect.fn.Return< - Query, P>, + Query, P>, never, - Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext + Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext > { const client = yield* QueryClient.QueryClient return new QueryImpl( - yield* Effect.context>(), + yield* Effect.context>(), options.key, options.f as any, options.initialProgress as P, @@ -304,12 +305,12 @@ export const make = Effect.fnUntraced(function* ( - options: make.Options +export const service = ( + options: make.Options ): Effect.Effect< - Query, P>, + Query, P>, never, - Scope.Scope | QueryClient.QueryClient | Result.forkEffect.OutputContext + Scope.Scope | QueryClient.QueryClient | KR | Result.forkEffect.OutputContext > => Effect.tap( make(options), query => Effect.forkScoped(query.run), From 346ba9066bd97bd7b65980012fdb7a941c49b7a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sun, 1 Mar 2026 14:34:50 +0100 Subject: [PATCH 10/31] Fix --- packages/effect-fc/src/Query.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/effect-fc/src/Query.ts b/packages/effect-fc/src/Query.ts index 6db26ea..78ccc0b 100644 --- a/packages/effect-fc/src/Query.ts +++ b/packages/effect-fc/src/Query.ts @@ -10,7 +10,7 @@ export interface Query + readonly context: Context.Context readonly key: Stream.Stream readonly f: (key: K) => Effect.Effect readonly initialProgress: P @@ -287,7 +287,7 @@ export const make = Effect.fnUntraced(function* { const client = yield* QueryClient.QueryClient - return new QueryImpl( + return new QueryImpl, P>( yield* Effect.context>(), options.key, options.f as any, From 2080d35b2cab486404480edbc29e590b7e4cc106 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 2 Mar 2026 04:02:21 +0100 Subject: [PATCH 11/31] Fix --- packages/effect-fc/src/Result.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/effect-fc/src/Result.ts b/packages/effect-fc/src/Result.ts index 3cef66c..a47f747 100644 --- a/packages/effect-fc/src/Result.ts +++ b/packages/effect-fc/src/Result.ts @@ -240,16 +240,16 @@ export const unsafeForkEffect = ( Effect.provide(Layer.empty.pipe( Layer.provideMerge(makeProgressLayer()), Layer.provideMerge(Layer.succeed(State(), { - 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), )), }), From 45b38d6c1f3332d71796438de8910d10f91481fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 3 Mar 2026 13:17:47 +0100 Subject: [PATCH 12/31] Refactor Async --- packages/effect-fc/src/Async.ts | 66 +++++++++------------------------ 1 file changed, 17 insertions(+), 49 deletions(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index af43edf..f35fc17 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -1,33 +1,30 @@ -/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ -import { Effect, Function, Predicate, Runtime, Scope } from "effect" +/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ +import { Effect, 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 +export interface Async extends AsyncPrototype {} +export interface AsyncPrototype { + readonly [AsyncTypeId]: AsyncTypeId } -export interface AsyncOptions { - readonly defaultFallback?: React.ReactNode -} +const PromiseTypeId: unique symbol = Symbol.for("@effect-fc/Async/Promise") -export type AsyncProps = Omit - - -export const AsyncPrototype = Object.freeze({ - [TypeId]: TypeId, +export const AsyncPrototype: AsyncPrototype = Object.freeze({ + [AsyncTypeId]: AsyncTypeId, asFunctionComponent

( this: Component.Component & Async, runtimeRef: React.RefObject>>, ) { - const SuspenseInner = (props: { readonly promise: Promise }) => React.use(props.promise) + const Inner = (props: { readonly [PromiseTypeId]: Promise }) => React.use(props[PromiseTypeId]) + Inner.displayName = `${ this.displayName }Inner` - return ({ fallback, name, ...props }: AsyncProps) => { + return (props: {}) => { const promise = Runtime.runPromise(runtimeRef.current)( Effect.andThen( Component.useScope([], this), @@ -35,49 +32,20 @@ export const AsyncPrototype = Object.freeze({ ) ) - return React.createElement( - React.Suspense, - { fallback: fallback ?? this.defaultFallback, name }, - React.createElement(SuspenseInner, { promise }), - ) + return React.createElement(Inner, { ...props, [PromiseTypeId]: promise }) } }, } as const) -export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, TypeId) +export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId) export const async = >( self: T -): ( - & Omit> - & Component.Component< - Component.Component.Props & AsyncProps, - Component.Component.Success, - Component.Component.Error, - Component.Component.Context - > - & Async -) => Object.setPrototypeOf( - Object.assign(function() {}, self), +): T & Async => Object.setPrototypeOf( + Object.assign(() => {}, self), Object.freeze(Object.setPrototypeOf( Object.assign({}, AsyncPrototype), Object.getPrototypeOf(self), )), ) - -export const withOptions: { - & Async>( - options: Partial - ): (self: T) => T - & Async>( - self: T, - options: Partial, - ): T -} = Function.dual(2, & Async>( - self: T, - options: Partial, -): T => Object.setPrototypeOf( - Object.assign(function() {}, self, options), - Object.getPrototypeOf(self), -)) From 7f8f91bfc52f70eed426ec06e6bcf2b88d311dd9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 3 Mar 2026 13:20:19 +0100 Subject: [PATCH 13/31] Fix --- packages/effect-fc/src/Async.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index f35fc17..d8e55b6 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -24,7 +24,7 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ const Inner = (props: { readonly [PromiseTypeId]: Promise }) => React.use(props[PromiseTypeId]) Inner.displayName = `${ this.displayName }Inner` - return (props: {}) => { + return (props: P) => { const promise = Runtime.runPromise(runtimeRef.current)( Effect.andThen( Component.useScope([], this), From 3794f56a86137731910d04bd19b3a470555d1124 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 3 Mar 2026 13:37:27 +0100 Subject: [PATCH 14/31] Async example --- packages/example/src/routeTree.gen.ts | 42 +++++----- packages/example/src/routes/async.tsx | 61 +++++++++++++++ .../src/routes/dev/async-rendering.tsx | 78 ------------------- 3 files changed, 82 insertions(+), 99 deletions(-) create mode 100644 packages/example/src/routes/async.tsx delete mode 100644 packages/example/src/routes/dev/async-rendering.tsx diff --git a/packages/example/src/routeTree.gen.ts b/packages/example/src/routeTree.gen.ts index 779c018..0e6de17 100644 --- a/packages/example/src/routeTree.gen.ts +++ b/packages/example/src/routeTree.gen.ts @@ -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, } diff --git a/packages/example/src/routes/async.tsx b/packages/example/src/routes/async.tsx new file mode 100644 index 0000000..d2c9d25 --- /dev/null +++ b/packages/example/src/routes/async.tsx @@ -0,0 +1,61 @@ +import { HttpClient } from "@effect/platform" +import { Container, Flex, Heading, Slider, Text } 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

+ {post.title} + {post.body} +
+}).pipe( + Async.async, + Memoized.memoized, +) {} + + +const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { + const [id, setId] = React.useState(1) + const AsyncFetchPost = yield* AsyncFetchPostView.use + + return ( + + + + + + + + ) +}).pipe( + Component.withRuntime(runtime.context) +) + +export const Route = createFileRoute("/async")({ + component: AsyncRouteComponent, +}) diff --git a/packages/example/src/routes/dev/async-rendering.tsx b/packages/example/src/routes/dev/async-rendering.tsx deleted file mode 100644 index c057745..0000000 --- a/packages/example/src/routes/dev/async-rendering.tsx +++ /dev/null @@ -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 ( - - setInput(e.target.value)} - /> - -

Loading memoized...

, [])} /> - -
- ) -}).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 }) => -// -// setInput(e.target.value)} -// /> - -// -// -// -// ), -// ).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 ( - - Rendered! - - - ) -}).pipe( - Async.async, - Async.withOptions({ defaultFallback:

Loading...

}), -) {} -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 {state} -}) {} - -export const Route = createFileRoute("/dev/async-rendering")({ - component: RouteComponent -}) From 1f478876435fffdc3242c620caa6735d139dae6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 3 Mar 2026 13:47:48 +0100 Subject: [PATCH 15/31] Fix --- packages/effect-fc/src/Async.ts | 6 ++---- packages/example/src/routes/async.tsx | 14 +++++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index d8e55b6..e69f570 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -12,8 +12,6 @@ export interface AsyncPrototype { readonly [AsyncTypeId]: AsyncTypeId } -const PromiseTypeId: unique symbol = Symbol.for("@effect-fc/Async/Promise") - export const AsyncPrototype: AsyncPrototype = Object.freeze({ [AsyncTypeId]: AsyncTypeId, @@ -21,7 +19,7 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ this: Component.Component & Async, runtimeRef: React.RefObject>>, ) { - const Inner = (props: { readonly [PromiseTypeId]: Promise }) => React.use(props[PromiseTypeId]) + const Inner = (props: { readonly promise: Promise }) => React.use(props.promise) Inner.displayName = `${ this.displayName }Inner` return (props: P) => { @@ -32,7 +30,7 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ ) ) - return React.createElement(Inner, { ...props, [PromiseTypeId]: promise }) + return React.createElement(Inner, { promise }) } }, } as const) diff --git a/packages/example/src/routes/async.tsx b/packages/example/src/routes/async.tsx index d2c9d25..602881d 100644 --- a/packages/example/src/routes/async.tsx +++ b/packages/example/src/routes/async.tsx @@ -26,10 +26,12 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*( Effect.andThen(Schema.decodeUnknown(Post)), ), [props.id]) - return
- {post.title} - {post.body} -
+ return ( +
+ {post.title} + {post.body} +
+ ) }).pipe( Async.async, Memoized.memoized, @@ -48,7 +50,9 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { onValueChange={flow(Array.head, Option.getOrThrow, setId)} /> - + Loading...}> + +
) From 929f835e94a35033da5038f3d0c8357b528e848d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 3 Mar 2026 15:35:23 +0100 Subject: [PATCH 16/31] Restore Async --- packages/effect-fc/src/Async.ts | 52 ++++++++++++++++++++++++++++----- 1 file changed, 44 insertions(+), 8 deletions(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index e69f570..5ae3b91 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -1,5 +1,5 @@ -/** biome-ignore-all lint/complexity/noBannedTypes: {} is the default type for React props */ -import { Effect, Predicate, Runtime, Scope } from "effect" +/** biome-ignore-all lint/complexity/useArrowFunction: necessary for class prototypes */ +import { Effect, Function, Predicate, Runtime, Scope } from "effect" import * as React from "react" import * as Component from "./Component.js" @@ -7,11 +7,19 @@ import * as Component from "./Component.js" export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") export type AsyncTypeId = typeof AsyncTypeId -export interface Async extends AsyncPrototype {} +export interface Async extends AsyncPrototype, AsyncOptions {} + export interface AsyncPrototype { readonly [AsyncTypeId]: AsyncTypeId } +export interface AsyncOptions { + readonly defaultFallback?: React.ReactNode +} + +export type AsyncProps = Omit + + export const AsyncPrototype: AsyncPrototype = Object.freeze({ [AsyncTypeId]: AsyncTypeId, @@ -20,9 +28,8 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ runtimeRef: React.RefObject>>, ) { const Inner = (props: { readonly promise: Promise }) => React.use(props.promise) - Inner.displayName = `${ this.displayName }Inner` - return (props: P) => { + return ({ fallback, name, ...props }: AsyncProps) => { const promise = Runtime.runPromise(runtimeRef.current)( Effect.andThen( Component.useScope([], this), @@ -30,7 +37,11 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ ) ) - return React.createElement(Inner, { promise }) + return React.createElement( + React.Suspense, + { fallback: fallback ?? this.defaultFallback, name }, + React.createElement(Inner, { promise }), + ) } }, } as const) @@ -40,10 +51,35 @@ export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, Asyn export const async = >( self: T -): T & Async => Object.setPrototypeOf( - Object.assign(() => {}, self), +): ( + & Omit> + & Component.Component< + Component.Component.Props & AsyncProps, + Component.Component.Success, + Component.Component.Error, + Component.Component.Context + > + & Async +) => Object.setPrototypeOf( + Object.assign(function() {}, self), Object.freeze(Object.setPrototypeOf( Object.assign({}, AsyncPrototype), Object.getPrototypeOf(self), )), ) + +export const withOptions: { + & Async>( + options: Partial + ): (self: T) => T + & Async>( + self: T, + options: Partial, + ): T +} = Function.dual(2, & Async>( + self: T, + options: Partial, +): T => Object.setPrototypeOf( + Object.assign(function() {}, self, options), + Object.getPrototypeOf(self), +)) From 8dd40d3365210c9b14ec4a3293d98fa811f4c368 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 3 Mar 2026 15:39:38 +0100 Subject: [PATCH 17/31] Fix --- packages/example/src/routes/async.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/example/src/routes/async.tsx b/packages/example/src/routes/async.tsx index 602881d..a443dab 100644 --- a/packages/example/src/routes/async.tsx +++ b/packages/example/src/routes/async.tsx @@ -34,6 +34,7 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*( ) }).pipe( Async.async, + Async.withOptions({ defaultFallback: Loading post... }), Memoized.memoized, ) {} @@ -50,9 +51,7 @@ const AsyncRouteComponent = Component.make("AsyncRouteView")(function*() { onValueChange={flow(Array.head, Option.getOrThrow, setId)} /> - Loading...}> - - + ) From 355e179fbd7a41021bfcb5caa139e280d2890afe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 3 Mar 2026 16:48:27 +0100 Subject: [PATCH 18/31] Fix --- packages/effect-fc/src/Async.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index 5ae3b91..53aebb6 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -50,7 +50,11 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId) export const async = >( - self: T + self: T & ( + "promise" extends keyof Component.Component.Props + ? "'promise' prop name is reserved for Async components. Please rename the 'promise' prop to something else." + : T + ) ): ( & Omit> & Component.Component< From dbe42aadb1193afe39706d28aad8bc675d136b1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Thu, 5 Mar 2026 11:39:51 +0100 Subject: [PATCH 19/31] Fix --- packages/effect-fc/src/Async.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index 53aebb6..dae8184 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -52,7 +52,7 @@ export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, Asyn export const async = >( self: T & ( "promise" extends keyof Component.Component.Props - ? "'promise' prop name is reserved for Async components. Please rename the 'promise' prop to something else." + ? "The 'promise' prop name is restricted for Async components. Please rename the 'promise' prop to something else." : T ) ): ( From df86af839ebb3e25aafc13a05e8cde6d63a0ed85 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Sat, 7 Mar 2026 19:45:23 +0100 Subject: [PATCH 20/31] Fix Async --- packages/effect-fc/src/Async.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index dae8184..ad458eb 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -1,5 +1,5 @@ /** 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" @@ -46,6 +46,30 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ }, } as const) +export const defaultPropsEquivalence: Equivalence.Equivalence = ( + self: Record, + that: Record, +) => { + if (self === that) + return true + + 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) @@ -65,7 +89,7 @@ export const async = >( > & Async ) => Object.setPrototypeOf( - Object.assign(function() {}, self), + Object.assign(function() {}, self, { propsEquivalence: defaultPropsEquivalence }), Object.freeze(Object.setPrototypeOf( Object.assign({}, AsyncPrototype), Object.getPrototypeOf(self), From b63d1ab2c745ac4ec5f6a13b9add821bdc47f755 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 9 Mar 2026 20:21:18 +0100 Subject: [PATCH 21/31] Fix --- packages/effect-fc/src/Component.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 0398dac..5e74bf6 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -4,8 +4,8 @@ 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 /** * Represents an Effect-based React Component that integrates the Effect system with React. @@ -13,7 +13,7 @@ export type TypeId = typeof TypeId export interface Component

extends ComponentPrototype, ComponentOptions { new(_: never): Record - readonly [TypeId]: TypeId + readonly [ComponentTypeId]: ComponentTypeId readonly "~Props": P readonly "~Success": A readonly "~Error": E @@ -34,7 +34,7 @@ export declare namespace Component { export interface ComponentPrototype

extends Pipeable.Pipeable { - readonly [TypeId]: TypeId + readonly [ComponentTypeId]: ComponentTypeId readonly use: Effect.Effect<(props: P) => A, never, Exclude> asFunctionComponent( @@ -46,7 +46,7 @@ extends Pipeable.Pipeable { } export const ComponentPrototype: ComponentPrototype = Object.freeze({ - [TypeId]: TypeId, + [ComponentTypeId]: ComponentTypeId, ...Pipeable.Prototype, get use() { return use(this) }, @@ -127,7 +127,7 @@ export const defaultOptions: ComponentOptions = { 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 = { From a9c0590b7c861cef49c7e33afdbcbc4a1e29def6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 10 Mar 2026 02:22:17 +0100 Subject: [PATCH 22/31] Fix Async example --- packages/example/src/routes/async.tsx | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/example/src/routes/async.tsx b/packages/example/src/routes/async.tsx index a443dab..62e0234 100644 --- a/packages/example/src/routes/async.tsx +++ b/packages/example/src/routes/async.tsx @@ -1,5 +1,5 @@ import { HttpClient } from "@effect/platform" -import { Container, Flex, Heading, Slider, Text } from "@radix-ui/themes" +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" @@ -34,24 +34,31 @@ class AsyncFetchPostView extends Component.make("AsyncFetchPostView")(function*( ) }).pipe( Async.async, - Async.withOptions({ defaultFallback: Loading post... }), + Async.withOptions({ defaultFallback: Default fallback }), 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 ( - + + setText(e.currentTarget.value)} + /> + - + Loading post...} /> ) From 8b69d4e5000fc9bd5af76ce23fc44e5b6fe2925e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 10 Mar 2026 02:35:43 +0100 Subject: [PATCH 23/31] Fix --- packages/effect-fc/src/Memoized.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/effect-fc/src/Memoized.ts b/packages/effect-fc/src/Memoized.ts index 33be421..bcffed0 100644 --- a/packages/effect-fc/src/Memoized.ts +++ b/packages/effect-fc/src/Memoized.ts @@ -4,11 +4,11 @@ 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

extends MemoizedOptions

{ - readonly [TypeId]: TypeId + readonly [MemoizedTypeId]: MemoizedTypeId } export interface MemoizedOptions

{ @@ -17,7 +17,7 @@ export interface MemoizedOptions

{ export const MemoizedPrototype = Object.freeze({ - [TypeId]: TypeId, + [MemoizedTypeId]: MemoizedTypeId, transformFunctionComponent

( this: Memoized

, @@ -28,7 +28,7 @@ export const MemoizedPrototype = Object.freeze({ } as const) -export const isMemoized = (u: unknown): u is Memoized => Predicate.hasProperty(u, TypeId) +export const isMemoized = (u: unknown): u is Memoized => Predicate.hasProperty(u, MemoizedTypeId) export const memoized = >( self: T From 9b3ce62d3e10847d310176cf271710dc298442fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 10 Mar 2026 02:43:33 +0100 Subject: [PATCH 24/31] Fix --- packages/effect-fc/src/Memoized.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/effect-fc/src/Memoized.ts b/packages/effect-fc/src/Memoized.ts index bcffed0..7176643 100644 --- a/packages/effect-fc/src/Memoized.ts +++ b/packages/effect-fc/src/Memoized.ts @@ -7,7 +7,9 @@ import type * as Component from "./Component.js" export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") export type MemoizedTypeId = typeof MemoizedTypeId -export interface Memoized

extends MemoizedOptions

{ +export interface Memoized

extends MemoizedPrototype, MemoizedOptions

{} + +export interface MemoizedPrototype { readonly [MemoizedTypeId]: MemoizedTypeId } @@ -16,7 +18,7 @@ export interface MemoizedOptions

{ } -export const MemoizedPrototype = Object.freeze({ +export const MemoizedPrototype: MemoizedPrototype = Object.freeze({ [MemoizedTypeId]: MemoizedTypeId, transformFunctionComponent

( From 6917c721017e987642e60508de69c8411860c020 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 10 Mar 2026 03:18:45 +0100 Subject: [PATCH 25/31] Add comments --- packages/effect-fc/src/Async.ts | 123 ++++++++++++++++++++++++++++- packages/effect-fc/src/Memoized.ts | 111 ++++++++++++++++++++++++++ 2 files changed, 233 insertions(+), 1 deletion(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index ad458eb..98ec0e9 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -4,25 +4,80 @@ import * as React from "react" import * as Component from "./Component.js" +/** + * A unique symbol representing the Async component type. + * Used as a type brand to identify Async components. + * + * @experimental + */ export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") -export type AsyncTypeId = typeof AsyncTypeId +/** + * The type of the Async type ID symbol. + */ +export type AsyncTypeId = typeof AsyncTypeId +/** + * An Async component that supports suspense and promise-based async operations. + * Combines Component behavior with Async-specific options. + * + * @example + * ```ts + * const MyAsyncComponent = async(component({ ... })) + * ``` + */ export interface Async extends AsyncPrototype, AsyncOptions {} + +/** + * The prototype object for Async components containing their methods and behaviors. + */ export interface AsyncPrototype { + /** + * The Async type ID brand. + */ 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, extending React.SuspenseProps without the children prop. + * The children are managed internally by the Async component. + */ export type AsyncProps = Omit +/** + * The prototype object for Async components. + * Provides the `asFunctionComponent` method for converting async components to React function components. + * + * @internal Use the `async` function to create Async components instead of accessing this directly. + */ export const AsyncPrototype: AsyncPrototype = Object.freeze({ [AsyncTypeId]: AsyncTypeId, + /** + * Converts an Async component to a React function component. + * + * @param runtimeRef - A reference to the Effect runtime for executing effects + * @returns A React function component that suspends while the async operation is executing + * + * @example + * ```ts + * const MyComponent = component({ ... }) + * const AsyncMyComponent = async(MyComponent) + * const FunctionComponent = AsyncMyComponent.asFunctionComponent(runtimeRef) + * ``` + */ asFunctionComponent

( this: Component.Component & Async, runtimeRef: React.RefObject>>, @@ -46,6 +101,16 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ }, } as const) +/** + * An equivalence function for comparing AsyncProps that ignores the `fallback` property. + * Useful for memoization and re-render optimization. + * + * @param self - The first props object to compare + * @param that - The second props object to compare + * @returns `true` if the props are equivalent (excluding fallback), `false` otherwise + * + * @internal + */ export const defaultPropsEquivalence: Equivalence.Equivalence = ( self: Record, that: Record, @@ -71,8 +136,43 @@ export const defaultPropsEquivalence: Equivalence.Equivalence = ( } +/** + * A type guard to check if a value is an Async component. + * + * @param u - The value to check + * @returns `true` if the value is an Async component, `false` otherwise + * + * @example + * ```ts + * if (isAsync(component)) { + * // component is an Async component + * } + * ``` + */ export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId) +/** + * Converts a Component into an Async component that supports suspense and promise-based async operations. + * + * The resulting component will wrap the original component with React.Suspense, + * allowing async Effect computations to suspend and resolve properly. + * + * 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 An Async component with the same body, error, and context types as the input + * + * @example + * ```ts + * const MyComponent = component({ + * body: (props) => // ... + * }) + * + * const AsyncMyComponent = async(MyComponent) + * ``` + * + * @throws Will produce a type error if the component has a "promise" prop + */ export const async = >( self: T & ( "promise" extends keyof Component.Component.Props @@ -96,6 +196,27 @@ export const async = >( )), ) +/** + * 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 + * const AsyncComponent = async(myComponent) + * + * // Uncurried + * const configured = withOptions(AsyncComponent, { defaultFallback: }) + * + * // Curried + * const configurer = withOptions({ defaultFallback: }) + * const configured = configurer(AsyncComponent) + * ``` + */ export const withOptions: { & Async>( options: Partial diff --git a/packages/effect-fc/src/Memoized.ts b/packages/effect-fc/src/Memoized.ts index 7176643..1e3edca 100644 --- a/packages/effect-fc/src/Memoized.ts +++ b/packages/effect-fc/src/Memoized.ts @@ -4,23 +4,80 @@ import * as React from "react" import type * as Component from "./Component.js" +/** + * A unique symbol representing the Memoized component type. + * Used as a type brand to identify Memoized components. + * + * @experimental + */ export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") + +/** + * The type of the Memoized type ID symbol. + */ export type MemoizedTypeId = typeof MemoizedTypeId + +/** + * A Memoized component that uses React.memo to optimize re-renders based on prop equality. + * Combines Component behavior with Memoized-specific options. + * + * @template P The props type of the component + * + * @example + * ```ts + * const MyComponent = component({ ... }) + * const MemoizedComponent = memoized(MyComponent) + * ``` + */ export interface Memoized

extends MemoizedPrototype, MemoizedOptions

{} +/** + * The prototype object for Memoized components containing their methods and behaviors. + */ export interface MemoizedPrototype { + /** + * The Memoized type ID brand. + */ readonly [MemoizedTypeId]: MemoizedTypeId } +/** + * Configuration options for Memoized components. + * + * @template P The props type of the component + */ export interface MemoizedOptions

{ + /** + * 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

} +/** + * The prototype object for Memoized components. + * Provides the `transformFunctionComponent` method for memoizing React function components. + * + * @internal Use the `memoized` function to create Memoized components instead of accessing this directly. + */ export const MemoizedPrototype: MemoizedPrototype = Object.freeze({ [MemoizedTypeId]: MemoizedTypeId, + /** + * Transforms a React function component by wrapping it with React.memo. + * + * @param f - The React function component to memoize + * @returns A memoized version of the component that uses the configured propsEquivalence function + * + * @example + * ```ts + * const MemoizedComponent = memoized(MyComponent) + * const Fn = MemoizedComponent.transformFunctionComponent((props) =>

{props.x}
) + * ``` + */ transformFunctionComponent

( this: Memoized

, f: React.FC

, @@ -30,8 +87,39 @@ export const MemoizedPrototype: MemoizedPrototype = Object.freeze({ } as const) +/** + * A type guard to check if a value is a Memoized component. + * + * @param u - The value to check + * @returns `true` if the value is a Memoized component, `false` otherwise + * + * @example + * ```ts + * if (isMemoized(component)) { + * // component is a Memoized component + * } + * ``` + */ export const isMemoized = (u: unknown): u is Memoized => Predicate.hasProperty(u, MemoizedTypeId) +/** + * Converts a Component into a Memoized component that optimizes re-renders using React.memo. + * + * The resulting component will use React.memo to skip re-renders when props haven't changed, + * based on the configured equivalence function (or the default equality check). + * + * @param self - The component to convert to a Memoized component + * @returns A Memoized component with the same body, error, and context types as the input + * + * @example + * ```ts + * const MyComponent = component({ + * body: (props) => // ... + * }) + * + * const MemoizedComponent = memoized(MyComponent) + * ``` + */ export const memoized = >( self: T ): T & Memoized> => Object.setPrototypeOf( @@ -42,6 +130,29 @@ export const memoized = >( )), ) +/** + * 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 + * const MemoizedComponent = memoized(MyComponent) + * + * // Uncurried + * const configured = withOptions(MemoizedComponent, { + * propsEquivalence: (a, b) => a.id === b.id + * }) + * + * // Curried + * const configurer = withOptions({ propsEquivalence: (a, b) => a.id === b.id }) + * const configured = configurer(MemoizedComponent) + * ``` + */ export const withOptions: { & Memoized>( options: Partial>> From c644f8c44bd2a789a7a109274d613ceec50d08e3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 10 Mar 2026 19:46:44 +0100 Subject: [PATCH 26/31] Fix Async docs --- packages/effect-fc/src/Async.ts | 108 +++++++------------------------- 1 file changed, 23 insertions(+), 85 deletions(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index 98ec0e9..527d1af 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -4,42 +4,21 @@ import * as React from "react" import * as Component from "./Component.js" -/** - * A unique symbol representing the Async component type. - * Used as a type brand to identify Async components. - * - * @experimental - */ export const AsyncTypeId: unique symbol = Symbol.for("@effect-fc/Async/Async") +export type AsyncTypeId = typeof AsyncTypeId + /** - * The type of the Async type ID symbol. - */ -export type AsyncTypeId = typeof AsyncTypeId -/** - * An Async component that supports suspense and promise-based async operations. - * Combines Component behavior with Async-specific options. - * - * @example - * ```ts - * const MyAsyncComponent = async(component({ ... })) - * ``` + * A trait for `Component`'s that allows them running asynchronous effects. */ export interface Async extends AsyncPrototype, AsyncOptions {} - -/** - * The prototype object for Async components containing their methods and behaviors. - */ export interface AsyncPrototype { - /** - * The Async type ID brand. - */ readonly [AsyncTypeId]: AsyncTypeId } /** - * Configuration options for Async components. + * Configuration options for `Async` components. */ export interface AsyncOptions { /** @@ -50,34 +29,14 @@ export interface AsyncOptions { } /** - * Props for Async components, extending React.SuspenseProps without the children prop. - * The children are managed internally by the Async component. + * Props for `Async` components. */ export type AsyncProps = Omit -/** - * The prototype object for Async components. - * Provides the `asFunctionComponent` method for converting async components to React function components. - * - * @internal Use the `async` function to create Async components instead of accessing this directly. - */ export const AsyncPrototype: AsyncPrototype = Object.freeze({ [AsyncTypeId]: AsyncTypeId, - /** - * Converts an Async component to a React function component. - * - * @param runtimeRef - A reference to the Effect runtime for executing effects - * @returns A React function component that suspends while the async operation is executing - * - * @example - * ```ts - * const MyComponent = component({ ... }) - * const AsyncMyComponent = async(MyComponent) - * const FunctionComponent = AsyncMyComponent.asFunctionComponent(runtimeRef) - * ``` - */ asFunctionComponent

( this: Component.Component & Async, runtimeRef: React.RefObject>>, @@ -102,14 +61,8 @@ export const AsyncPrototype: AsyncPrototype = Object.freeze({ } as const) /** - * An equivalence function for comparing AsyncProps that ignores the `fallback` property. - * Useful for memoization and re-render optimization. - * - * @param self - The first props object to compare - * @param that - The second props object to compare - * @returns `true` if the props are equivalent (excluding fallback), `false` otherwise - * - * @internal + * 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 = ( self: Record, @@ -136,42 +89,22 @@ export const defaultPropsEquivalence: Equivalence.Equivalence = ( } -/** - * A type guard to check if a value is an Async component. - * - * @param u - The value to check - * @returns `true` if the value is an Async component, `false` otherwise - * - * @example - * ```ts - * if (isAsync(component)) { - * // component is an Async component - * } - * ``` - */ export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, AsyncTypeId) /** - * Converts a Component into an Async component that supports suspense and promise-based async operations. - * - * The resulting component will wrap the original component with React.Suspense, - * allowing async Effect computations to suspend and resolve properly. + * 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 An Async component with the same body, error, and context types as the input + * @returns A new Async component with the same body, error, and context types as the input * * @example * ```ts - * const MyComponent = component({ - * body: (props) => // ... - * }) - * - * const AsyncMyComponent = async(MyComponent) + * const MyAsyncComponent = Component.make("MyComponent")(function*() { ... }).pipe( + * Async.async, + * ) * ``` - * - * @throws Will produce a type error if the component has a "promise" prop */ export const async = >( self: T & ( @@ -207,14 +140,19 @@ export const async = >( * * @example * ```ts - * const AsyncComponent = async(myComponent) + * // Curried + * const MyAsyncComponent = Component.make("MyComponent")(function*() { ... }).pipe( + * Async.async, + * Async.withOptions({ defaultFallback:

Loading...

}), + * ) * * // Uncurried - * const configured = withOptions(AsyncComponent, { defaultFallback: }) - * - * // Curried - * const configurer = withOptions({ defaultFallback: }) - * const configured = configurer(AsyncComponent) + * const MyAsyncComponent = Component.make("MyComponent")(function*() { ... }).pipe( + * Async.async, + * ) + * const MyAsyncComponentWithOptions = Async.withOptions(MyAsyncComponent, { + * defaultFallback:

Loading...

, + * }) * ``` */ export const withOptions: { From 0ba00a0b4ff92211348709542e9bad621d11b4c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 10 Mar 2026 20:48:31 +0100 Subject: [PATCH 27/31] Make nonReactiveTags a Component option --- packages/effect-fc/src/Component.ts | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 5e74bf6..63ce0d5 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -88,7 +88,7 @@ const use = Effect.fnUntraced(function*

[] + /** * Specifies the execution strategy for finalizers when the component unmounts or its scope closes. * Determines whether finalizers execute sequentially or in parallel. @@ -119,14 +120,12 @@ export interface ComponentOptions { } 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, ComponentTypeId) export declare namespace make { From 2f118c5f9856cf9efafba599526c0f21b9566b83 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 10 Mar 2026 20:49:13 +0100 Subject: [PATCH 28/31] Fix --- packages/effect-fc/src/Component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 63ce0d5..ac5e8f9 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -88,7 +88,7 @@ const use = Effect.fnUntraced(function*

[] + readonly nonReactiveTags: readonly Context.Tag[] /** * Specifies the execution strategy for finalizers when the component unmounts or its scope closes. From 46d7aacc696fcc3aab43d2c5760895e88ac8a04f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Tue, 10 Mar 2026 20:51:04 +0100 Subject: [PATCH 29/31] Add comments --- packages/effect-fc/src/Component.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index ac5e8f9..3b76676 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -99,6 +99,11 @@ export interface ComponentOptions { */ readonly displayName?: string + /** + * Context tags that should not trigger component remount when their values change. + * + * @default [Tracer.ParentSpan] + */ readonly nonReactiveTags: readonly Context.Tag[] /** From 3cb3f6d10309b3d0f1e73f9a68b11757dd35c694 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 11 Mar 2026 21:20:20 +0100 Subject: [PATCH 30/31] Update docs --- packages/effect-fc/src/Async.ts | 14 ++--- packages/effect-fc/src/Component.ts | 2 +- packages/effect-fc/src/Memoized.ts | 88 +++++------------------------ 3 files changed, 22 insertions(+), 82 deletions(-) diff --git a/packages/effect-fc/src/Async.ts b/packages/effect-fc/src/Async.ts index 527d1af..03b158d 100644 --- a/packages/effect-fc/src/Async.ts +++ b/packages/effect-fc/src/Async.ts @@ -97,11 +97,11 @@ export const isAsync = (u: unknown): u is Async => Predicate.hasProperty(u, Asyn * 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 + * @returns A new `Async` component with the same body, error, and context types as the input * * @example * ```ts - * const MyAsyncComponent = Component.make("MyComponent")(function*() { ... }).pipe( + * const MyAsyncComponent = MyComponent.pipe( * Async.async, * ) * ``` @@ -141,18 +141,16 @@ export const async = >( * @example * ```ts * // Curried - * const MyAsyncComponent = Component.make("MyComponent")(function*() { ... }).pipe( + * const MyAsyncComponent = MyComponent.pipe( * Async.async, * Async.withOptions({ defaultFallback:

Loading...

}), * ) * * // Uncurried - * const MyAsyncComponent = Component.make("MyComponent")(function*() { ... }).pipe( - * Async.async, + * const MyAsyncComponent = Async.withOptions( + * Async.async(MyComponent), + * { defaultFallback:

Loading...

}, * ) - * const MyAsyncComponentWithOptions = Async.withOptions(MyAsyncComponent, { - * defaultFallback:

Loading...

, - * }) * ``` */ export const withOptions: { diff --git a/packages/effect-fc/src/Component.ts b/packages/effect-fc/src/Component.ts index 3b76676..8c61e08 100644 --- a/packages/effect-fc/src/Component.ts +++ b/packages/effect-fc/src/Component.ts @@ -511,7 +511,7 @@ export const makeUntraced: ( * const MyComponentWithCustomOptions = MyComponent.pipe( * Component.withOptions({ * finalizerExecutionStrategy: ExecutionStrategy.parallel, - * finalizerExecutionDebounce: "50 millis" + * finalizerExecutionDebounce: "50 millis", * }) * ) * ``` diff --git a/packages/effect-fc/src/Memoized.ts b/packages/effect-fc/src/Memoized.ts index 1e3edca..804d59f 100644 --- a/packages/effect-fc/src/Memoized.ts +++ b/packages/effect-fc/src/Memoized.ts @@ -4,41 +4,18 @@ import * as React from "react" import type * as Component from "./Component.js" -/** - * A unique symbol representing the Memoized component type. - * Used as a type brand to identify Memoized components. - * - * @experimental - */ export const MemoizedTypeId: unique symbol = Symbol.for("@effect-fc/Memoized/Memoized") - -/** - * The type of the Memoized type ID symbol. - */ export type MemoizedTypeId = typeof MemoizedTypeId /** - * A Memoized component that uses React.memo to optimize re-renders based on prop equality. - * Combines Component behavior with Memoized-specific options. + * 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 - * - * @example - * ```ts - * const MyComponent = component({ ... }) - * const MemoizedComponent = memoized(MyComponent) - * ``` */ export interface Memoized

extends MemoizedPrototype, MemoizedOptions

{} -/** - * The prototype object for Memoized components containing their methods and behaviors. - */ export interface MemoizedPrototype { - /** - * The Memoized type ID brand. - */ readonly [MemoizedTypeId]: MemoizedTypeId } @@ -57,27 +34,9 @@ export interface MemoizedOptions

{ } -/** - * The prototype object for Memoized components. - * Provides the `transformFunctionComponent` method for memoizing React function components. - * - * @internal Use the `memoized` function to create Memoized components instead of accessing this directly. - */ export const MemoizedPrototype: MemoizedPrototype = Object.freeze({ [MemoizedTypeId]: MemoizedTypeId, - /** - * Transforms a React function component by wrapping it with React.memo. - * - * @param f - The React function component to memoize - * @returns A memoized version of the component that uses the configured propsEquivalence function - * - * @example - * ```ts - * const MemoizedComponent = memoized(MyComponent) - * const Fn = MemoizedComponent.transformFunctionComponent((props) =>

{props.x}
) - * ``` - */ transformFunctionComponent

( this: Memoized

, f: React.FC

, @@ -87,37 +46,19 @@ export const MemoizedPrototype: MemoizedPrototype = Object.freeze({ } as const) -/** - * A type guard to check if a value is a Memoized component. - * - * @param u - The value to check - * @returns `true` if the value is a Memoized component, `false` otherwise - * - * @example - * ```ts - * if (isMemoized(component)) { - * // component is a Memoized component - * } - * ``` - */ export const isMemoized = (u: unknown): u is Memoized => Predicate.hasProperty(u, MemoizedTypeId) /** - * Converts a Component into a Memoized component that optimizes re-renders using React.memo. - * - * The resulting component will use React.memo to skip re-renders when props haven't changed, - * based on the configured equivalence function (or the default equality check). + * 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 Memoized component with the same body, error, and context types as the input + * @returns A new `Memoized` component with the same body, error, and context types as the input * * @example * ```ts - * const MyComponent = component({ - * body: (props) => // ... - * }) - * - * const MemoizedComponent = memoized(MyComponent) + * const MyMemoizedComponent = MyComponent.pipe( + * Memoized.memoized, + * ) * ``` */ export const memoized = >( @@ -141,16 +82,17 @@ export const memoized = >( * * @example * ```ts - * const MemoizedComponent = memoized(MyComponent) + * // Curried + * const MyMemoizedComponent = MyComponent.pipe( + * Memoized.memoized, + * Memoized.withOptions({ propsEquivalence: (a, b) => a.id === b.id }), + * ) * * // Uncurried - * const configured = withOptions(MemoizedComponent, { - * propsEquivalence: (a, b) => a.id === b.id - * }) - * - * // Curried - * const configurer = withOptions({ propsEquivalence: (a, b) => a.id === b.id }) - * const configured = configurer(MemoizedComponent) + * const MyMemoizedComponent = Memoized.withOptions( + * Memoized.memoized(MyComponent), + * { propsEquivalence: (a, b) => a.id === b.id }, + * ) * ``` */ export const withOptions: { From 2457b1c536ab0d191d103eb303f906ab816f3d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Wed, 11 Mar 2026 21:42:19 +0100 Subject: [PATCH 31/31] Fix --- packages/effect-fc/src/ErrorObserver.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/effect-fc/src/ErrorObserver.ts b/packages/effect-fc/src/ErrorObserver.ts index 30f5424..75930af 100644 --- a/packages/effect-fc/src/ErrorObserver.ts +++ b/packages/effect-fc/src/ErrorObserver.ts @@ -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 extends Pipeable.Pipeable { - readonly [TypeId]: TypeId + readonly [ErrorObserverTypeId]: ErrorObserverTypeId handle(effect: Effect.Effect): Effect.Effect readonly subscribe: Effect.Effect>, never, Scope.Scope> } export const ErrorObserver = (): Context.Tag> => Context.GenericTag("@effect-fc/ErrorObserver/ErrorObserver") -class ErrorObserverImpl +export class ErrorObserverImpl extends Pipeable.Class() implements ErrorObserver { - readonly [TypeId]: TypeId = TypeId + readonly [ErrorObserverTypeId]: ErrorObserverTypeId = ErrorObserverTypeId readonly subscribe: Effect.Effect>, never, Scope.Scope> constructor( @@ -29,7 +29,7 @@ extends Pipeable.Class() implements ErrorObserver { } } -class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor { +export class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor { readonly value = Effect.void constructor(readonly pubsub: PubSub.PubSub>) { super() @@ -43,7 +43,7 @@ class ErrorObserverSupervisorImpl extends Supervisor.AbstractSupervisor { } -export const isErrorObserver = (u: unknown): u is ErrorObserver => Predicate.hasProperty(u, TypeId) +export const isErrorObserver = (u: unknown): u is ErrorObserver => Predicate.hasProperty(u, ErrorObserverTypeId) export const layer: Layer.Layer = Layer.unwrapEffect(Effect.map( PubSub.unbounded>(),