Files
effect-view/packages/effect-view/ai-docs/Component.md
T
ThilawynandJulien Valverdé 0e2b4c1951
Publish / publish (push) Successful in 2m6s
Lint / lint (push) Successful in 1m3s
Improve effect-view docs, mutation keys, and Fast Refresh (#76)
## Summary

- Add comprehensive AI-oriented documentation for effect-view components, state, async rendering, queries, mutations, forms, streams, and runtime setup.
- Fix `Mutation.mutate` so each invocation consistently uses its own key instead of reusing the previous mutation key.
- Improve Vite Fast Refresh instrumentation for:
  - User-defined component wrappers.
  - Data-first and pipeline-based `withContext`/`withRuntime` entrypoints.
  - Nested function handling.
  - Stable hook signatures that preserve state for non-hook-related edits.
- Add regression tests for mutation keys and refresh behavior.
- Bump `effect-view` to `0.1.5` and `@effect-view/vite-plugin` to `0.0.2`.

## Testing

- `bun run --cwd packages/effect-view test -- src/Mutation.test.ts`
  - 4 tests passed
- `bun run --cwd packages/vite-plugin test -- src/plugin.test.ts`
  - 11 tests passed

The full test suite was not run.

---------

Co-authored-by: Julien Valverdé <julien.valverde@mailo.com>
Reviewed-on: #76
2026-08-26 01:06:03 +02:00

5.5 KiB

Component

Defines a React function component as an Effect program. A Component is a description, not yet a React component — cross into React with Component.withContext or .use.

Define

import { Effect } from "effect"
import { Component } from "effect-view"

export const HelloView = Component.make("HelloView")(function* (props: { readonly name: string }) {
  const message = yield* Effect.succeed(`Hello, ${props.name}`)
  return <h1>{message}</h1>
})
  • Component.make(spanName?)(generatorBody, ...pipeArgs) — same overloads as Effect.fn/Effect.gen: a generator body, or a body plus (_, props) => next pipeline steps. Passing a spanName wraps the body in a tracing span and sets displayName.
  • Component.makeUntraced is identical but skips the automatic span (still sets displayName from the name argument).
  • The component's props type, return type, error channel, and required services (R) are all inferred from the generator body.

Cross into React

export const Hello = HelloView.pipe(Component.withContext(runtime.context))
// <Hello name="Effect" />

Component.withContext(context) reads the Effect context supplied by the matching ReactRuntime.Provider and turns the component into a plain React.FC. Apply it only at boundaries where plain React (a router, a third-party lib, an app root) needs a function component — never between two effect-view components.

Compose inside effect-view

const Hello = yield* HelloView.use
return <Hello name="Effect" />

component.use is an Effect<F, never, Exclude<R, Scope.Scope>> that binds the child to the current Effect context/scope and returns a stable function-component reference. Yield it from a parent component body; do not call withContext here.

Lifecycle hooks

Hooks are plain React hooks under the hood: call them unconditionally, at the top level, in the same order every render — never in branches, loops, callbacks, or after a suspend point.

Every rendered component instance gets a root Scope.Scope, created on mount and closed on unmount, provided to the whole body. Effect.addFinalizer, Effect.acquireRelease, Effect.forkScoped used directly in the body run against this scope.

Hook Purpose Scope Closes
body produce rendered output component root scope unmount
useOnMount(() => effect) compute + cache once component root scope unmount
useOnChange(() => effect, deps) recompute on deps change new scope per dep set deps change / unmount
useReactEffect(() => effect, deps?) post-commit side effect (Effect.useEffect analog) new scope deps change / unmount
useReactLayoutEffect(() => effect, deps?) pre-paint side effect new scope deps change / unmount
useLayer(layer, options?) build + provide a Layer, returns its Context new scope tied to layer identity layer ref changes / unmount
const state = yield* Component.useOnMount(() =>
  Effect.gen(function* () {
    yield* Effect.addFinalizer(() => Effect.log("disposed"))
    return Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
  }),
)
  • Setup passed to useOnMount/useOnChange/useLayer must complete synchronously in a regular component (it runs during render). Wrap the component with Async.async to allow suspending setup.
  • useReactEffect/useReactLayoutEffect setup must also start synchronously but may Effect.forkScoped async work into the hook's own scope.
  • useRunSync/useRunPromise/useCallbackSync/useCallbackPromise do not create a new scope; they capture the component root scope (and any extra services you request) by default.

Run Effects from event handlers

const runPromise = yield* Component.useRunPromise() // or useRunPromise<Scope.Scope | SomeService>()
<button onClick={() => void runPromise(saveUser(user))}>Save</button>
  • useRunSync<R>() — only for Effects guaranteed to complete synchronously.
  • useRunPromise<R>() — for Effects that may suspend/sleep/fetch.
  • useCallbackSync(f, deps) / useCallbackPromise(f, deps) — memoized variants (same deps semantics as React.useCallback) for passing stable callbacks to children.
  • Both runners provide Scope.Scope automatically; add extra services with an explicit type argument (useRunPromise<Scope.Scope | UserRepository>()).

Provide services

Static layer, one instance per mounted component, disposed on unmount:

const GreetingViewLive = GreetingView.pipe(Component.provide(GreetingService.layer))

Layer built from render-time state (props/context), provided to children explicitly:

const layer = React.useMemo(() => Layer.succeed(GreetingService, {...}), [props.greeting])
const context = yield* Component.useLayer(layer)
const Greeting = yield* Effect.provide(GreetingView.use, context)
return <Greeting name="Effect" />

Keep layer references stable (module scope or React.useMemo) — a new layer object triggers rebuild and finalizer cleanup. Async layer construction requires Async.async on the owning component.

Common pitfalls

  • Never yield an asynchronous Effect from a regular component body — use Async.async, Query, a Mutation callback, or a scoped fiber forked from a post-commit hook instead.
  • Component.withContext needs a matching ReactRuntime.Provider above it in the tree.
  • Prefer useRunPromise over useRunSync for event handlers that may be async.
  • Regular React hooks, refs, context, and state work normally inside a component body alongside yield*.