Add Effect v4 support, Fast Refresh tooling, and revamped docs #56

Merged
Thilawyn merged 133 commits from next into master 2026-07-26 02:32:59 +02:00
Showing only changes of commit 36c47e1b4b - Show all commits
+297 -222
View File
@@ -5,50 +5,40 @@ title: Getting Started
# Getting Started # Getting Started
`effect-view` lets React components be written as Effect programs. Inside a `effect-view` lets a React function component be described as an Effect
component body you can yield services, run Effects, subscribe to Effect-powered program. Inside a component you can yield services, create scoped resources,
state, and still export a normal React function component at the edge of your subscribe to Effect-powered state, and turn Effects into React callbacks. At a
app. React boundary, that description becomes a normal function component.
The core model has four pieces:
1. `ReactRuntime` builds the Effect services available to the UI.
2. `Component.make` defines an Effect View component.
3. `Component.withContext` converts an Effect View component into a normal
React component at an application or router boundary.
4. Effect View children are composed through their `.use` Effect.
## Install ## Install
Install `effect-view` alongside Effect 4 and React 19.2 or newer: For a web application, install Effect View with Effect 4 and React 19.2 or
newer:
```bash npm2yarn ```bash npm2yarn
npm install effect-view effect react npm install effect-view effect react react-dom
``` ```
```bash npm2yarn
npm install --save-dev @types/react
```
`effect-view` is not opinionated about the React platform. Use it with web,
native, custom renderers, or any environment where React components can run.
Then install the platform-specific React packages for your target.
For web apps, install React DOM:
```bash npm2yarn ```bash npm2yarn
npm install react-dom npm install --save-dev @types/react @types/react-dom
```
```bash npm2yarn
npm install --save-dev @types/react-dom
``` ```
## Create A Runtime Effect View is not tied to React DOM. For React Native or another renderer,
install that renderer instead of `react-dom` and keep the rest of the setup the
same.
An Effect View app needs an Effect runtime. Build one from the services your UI ## Create the runtime
needs, then share it with React through `ReactRuntime.Provider`.
For an empty app, `Layer.empty` is enough: `ReactRuntime` owns a managed Effect runtime. Define it at module scope from the
layers needed by the UI:
```tsx title="src/runtime.ts"
import { Layer } from "effect"
import { ReactRuntime } from "effect-view"
export const runtime = ReactRuntime.make(Layer.empty)
```
As your app grows, add services to the layer:
```tsx title="src/runtime.ts" ```tsx title="src/runtime.ts"
import { Layer } from "effect" import { Layer } from "effect"
@@ -62,9 +52,23 @@ const AppLive = Layer.empty.pipe(
export const runtime = ReactRuntime.make(AppLive) export const runtime = ReactRuntime.make(AppLive)
``` ```
## Provide The Runtime Use `Layer.empty` by itself when the application does not need any additional
services yet:
At the React root, wrap your app with `ReactRuntime.Provider`: ```tsx
export const runtime = ReactRuntime.make(Layer.empty)
```
Keep the runtime stable. Creating it during a React render would create new
managed resources and a new React context on every render.
## Provide the runtime
Place `ReactRuntime.Provider` above every Effect View entrypoint. The provider
builds the runtime layer, makes its Effect context available through React, and
disposes the managed runtime when the provider unmounts.
Runtime construction can suspend, so give the provider a fallback:
```tsx title="src/main.tsx" ```tsx title="src/main.tsx"
import { StrictMode } from "react" import { StrictMode } from "react"
@@ -75,47 +79,52 @@ import { runtime } from "./runtime"
createRoot(document.getElementById("root")!).render( createRoot(document.getElementById("root")!).render(
<StrictMode> <StrictMode>
<ReactRuntime.Provider runtime={runtime}> <ReactRuntime.Provider
runtime={runtime}
fallback={<p>Starting application...</p>}
>
<App /> <App />
</ReactRuntime.Provider> </ReactRuntime.Provider>
</StrictMode>, </StrictMode>,
) )
``` ```
`ReactRuntime.Provider` also works with routers. Keep it above your router With a router, keep the provider above the router provider:
provider so route components can use the same runtime context.
## Write Your First Component ```tsx
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
<RouterProvider router={router} />
</ReactRuntime.Provider>
```
Use `Component.make` when you want automatic tracing spans, or If runtime layer construction can fail, place an appropriate React error
`Component.makeUntraced` when you only want the component behavior. This creates boundary above `ReactRuntime.Provider` as well.
an Effect View component, not a plain React component yet.
The Effect run during render must be synchronous. Effect View follows React's ## Write your first component
render model, so component bodies should produce JSX without waiting on async
work. Use lifecycle hooks, callbacks, queries, or other Effect View helpers for `Component.make` defines a component body using the same generator style as
async work that happens outside render. `Effect.gen` and `Effect.fn`. Providing a name creates a tracing span and also
sets the React DevTools display name:
```tsx title="src/HelloView.tsx" ```tsx title="src/HelloView.tsx"
import { Effect } from "effect" import { Effect } from "effect"
import { Component } from "effect-view" import { Component } from "effect-view"
export const HelloView = Component.make("HelloView")(function* (props: { export const HelloView = Component.make("HelloView")(
readonly name: string function* (props: { readonly name: string }) {
}) {
const message = yield* Effect.succeed(`Hello, ${props.name}`) const message = yield* Effect.succeed(`Hello, ${props.name}`)
return <h1>{message}</h1> return <h1>{message}</h1>
}) },
)
``` ```
At this point `HelloView` can yield Effects, services, and scoped lifecycle Use `Component.makeUntraced("HelloView")` when you want the display name but do
work, but React cannot render it directly. not want an automatic tracing span.
## Apply A Runtime `HelloView` is an Effect View component description, not yet a normal React
component. Convert it at the point where plain React, a router, or a third-party
Use `Component.withContext` at the boundary where an Effect View component needs library needs a function component:
to become a regular React function component.
```tsx title="src/Hello.tsx" ```tsx title="src/Hello.tsx"
import { Component } from "effect-view" import { Component } from "effect-view"
@@ -127,7 +136,7 @@ export const Hello = HelloView.pipe(
) )
``` ```
`Hello` is now a normal React component: It can now be rendered by ordinary React:
```tsx title="src/App.tsx" ```tsx title="src/App.tsx"
import { Hello } from "./Hello" import { Hello } from "./Hello"
@@ -137,11 +146,14 @@ export function App() {
} }
``` ```
## Use Effect View Components Together `Component.withContext` reads the context populated by the matching
`ReactRuntime.Provider`. It does not build or provide the runtime by itself.
Inside an Effect View component, other Effect View components are available through ## Compose Effect View components
their `.use` effect. Yield `.use` to get a React component for the current
runtime context, then render it like JSX. Inside another Effect View component, yield a child's `.use` Effect. This binds
the child to the current Effect context and returns a component that can be used
in JSX:
```tsx title="src/GreetingCardView.tsx" ```tsx title="src/GreetingCardView.tsx"
import { Component } from "effect-view" import { Component } from "effect-view"
@@ -154,173 +166,243 @@ export const GreetingCardView = Component.make("GreetingCard")(
return ( return (
<section> <section>
<Hello name="Effect" /> <Hello name="Effect" />
<p>This component is still running inside Effect View.</p> <p>Both components use the same Effect context.</p>
</section> </section>
) )
}, },
) )
``` ```
Use `Component.withContext` only when you leave the Effect View tree and need a Only apply `Component.withContext` when crossing from Effect View into plain
normal React component again: React. Applying it to every nested component is unnecessary and makes it harder
to provide local services to a subtree.
```tsx title="src/GreetingCard.tsx" ## Synchronous and asynchronous components
import { Component } from "effect-view"
import { GreetingCardView } from "./GreetingCardView"
import { runtime } from "./runtime"
export const GreetingCard = GreetingCardView.pipe( A regular Effect View component runs its body during React render. That Effect
Component.withContext(runtime.context), must complete synchronously: yielding a service, reading synchronous state, or
creating a scoped object is fine; sleeping, fetching, or awaiting a promise is
not.
Use `Async.async` when render genuinely depends on an asynchronous Effect. The
component then suspends and accepts React Suspense props such as `fallback`:
```tsx title="src/UserView.tsx"
import { Async, Component, Memoized } from "effect-view"
import { loadUser } from "./api"
export const UserView = Component.make("UserView")(
function* (props: { readonly userId: string }) {
const user = yield* Component.useOnChange(
() => loadUser(props.userId),
[props.userId],
)
return <h2>{user.name}</h2>
},
).pipe(
Async.async,
Async.withOptions({ defaultFallback: <p>Loading user...</p> }),
Memoized.memoized,
) )
``` ```
## Component Lifecycle Use it from an Effect View parent in the usual way:
Every Effect View component instance exposes an Effect `Scope`. Effects that need ```tsx
`Scope.Scope` can use that component scope to register finalizers, fork scoped const User = yield* UserView.use
work, or acquire scoped resources.
The component scope is created when React mounts the component and closes when return <User userId={selectedUserId} />
React unmounts it. When the scope closes, Effect runs the finalizers registered ```
inside that scope.
Finalizers are forked when the scope closes, so cleanup logic can run `Memoized.memoized` prevents an unrelated parent re-render from restarting an
asynchronous Effects even though the component body itself must stay async child whose props have not changed. A changed `userId` creates a new
synchronous. dependency scope, cleans up the previous one, and runs `loadUser` again.
```tsx title="src/MountedMessageView.tsx" An async component's rejected Effect is handled by the nearest React error
import { Console, Effect } from "effect" boundary. Suspense handles waiting, not failures.
For server data that should be cached, refreshed, and shared, prefer the
[Query module](./query) over a raw async component.
## Use Effect services
Components can yield Effect service tags directly. Their required service type
becomes part of the component type, so the final runtime or a local layer must
provide it:
```tsx title="src/GreetingService.ts"
import { Context, Layer } from "effect"
export class GreetingService extends Context.Service<
GreetingService,
{ readonly greet: (name: string) => string }
>()("GreetingService") {
static readonly layer = Layer.succeed(GreetingService, {
greet: (name) => `Hello, ${name}`,
})
}
```
```tsx title="src/GreetingView.tsx"
import { Component } from "effect-view" import { Component } from "effect-view"
import { GreetingService } from "./GreetingService"
export const MountedMessageView = Component.make("MountedMessage")( export const GreetingView = Component.make("Greeting")(
function* () { function* (props: { readonly name: string }) {
const message = yield* Component.useOnMount(() => const greeting = yield* GreetingService
Effect.gen(function* () {
yield* Console.log("MountedMessage mounted")
yield* Effect.addFinalizer(() =>
Console.log("MountedMessage unmounted"),
)
return "This value was loaded on mount." return <p>{greeting.greet(props.name)}</p>
}),
)
return <p>{message}</p>
}, },
) )
``` ```
Use `Component.useOnMount` when the component needs a value produced by an Add `GreetingService.layer` to the application runtime, or provide it only to a
Effect during its first render. The value is then cached for the component subtree as shown later on this page.
instance. You can also use it to set up scoped component logic, subscriptions,
or resources that should live for the lifetime of that component instance.
Use `Component.useOnChange` when render needs the value computed by Effect service instances are reactive at component boundaries. If the supplied
scoped work that depends on changing inputs. When a dependency changes, React context changes to contain a different service instance, dependent Effect View
re-renders the component and the hook computes the next value during that components are recreated so they read the new environment and restart their
render. scoped lifecycle.
## Understand lifecycle hooks
Effect View hooks are still React hooks internally. Call them unconditionally
at the top level of the component body, in a consistent order, and never inside
branches, loops, event handlers, or nested callbacks.
The main lifecycle choices are:
| Need | Hook | When setup runs |
| --- | --- | --- |
| Compute and cache a value for this component instance | `useOnMount` | During the initial render |
| Recompute a value when dependencies change | `useOnChange` | During render when dependencies change |
| Run a post-commit side effect | `useReactEffect` | After React commits |
| Measure or update layout before paint | `useReactLayoutEffect` | After DOM mutation, before paint |
### useOnMount
`Component.useOnMount` computes a value during the component's initial render
and returns the cached value on later renders. It receives the component scope,
so acquired resources and forked fibers can be tied to the component lifetime:
```tsx title="src/LocalStateView.tsx"
import { Effect, SubscriptionRef } from "effect"
import { Component, Lens, View } from "effect-view"
export const LocalStateView = Component.make("LocalState")(
function* () {
const count = yield* Component.useOnMount(() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.log("LocalState disposed"),
)
return Lens.fromSubscriptionRef(
yield* SubscriptionRef.make(0),
)
}),
)
const [value] = yield* View.useAll([count])
return <p>Count: {value}</p>
},
)
```
In a regular component, the setup Effect must be synchronous because it runs
during render. In a component enhanced with `Async.async`, it may be
asynchronous and will suspend the component.
### useOnChange
`Component.useOnChange` has the same render-time constraint, but owns a scope
for one dependency window. When a dependency changes, the previous scope is
closed and a new value is computed:
```tsx ```tsx
import { Console, Effect } from "effect" const label = yield* Component.useOnChange(
import { Component } from "effect-view"
const UserPanelView = Component.make("UserPanel")(
function* (props: { readonly userId: string }) {
const label = yield* Component.useOnChange(
() => () =>
Effect.gen(function* () { Effect.gen(function* () {
yield* Console.log(`Preparing view for ${props.userId}`)
yield* Effect.addFinalizer(() => yield* Effect.addFinalizer(() =>
Console.log(`Cleaning up ${props.userId}`), Effect.log(`Stopped viewing ${props.userId}`),
) )
return `Viewing user ${props.userId}` return `Viewing user ${props.userId}`
}), }),
[props.userId], [props.userId],
)
return <p>{label}</p>
},
) )
``` ```
In this example, each `userId` gets its own scope. When `userId` changes, Dependency arrays follow normal React semantics. Include every reactive value
Effect View closes the previous scope, runs its finalizers, and creates a new read by the setup Effect.
scope for the next load. Unlike `useOnMount`, `useOnChange` does not expose the
component's root scope directly. It creates and provides its own scope for that
dependency window. Some other Effect View hooks follow the same pattern when they
need a lifecycle that is narrower than the whole component instance.
## Useful Effect View Hooks ### useReactEffect and useReactLayoutEffect
Unlike plain React hooks, Effect View hooks return Effects. Use `yield*` to run Use `Component.useReactEffect` for work that should start only after React has
them inside the component body. committed the render. Setup must complete synchronously, but it can fork
asynchronous work into the provided scope:
The most common Effect View hooks are:
- `Component.useOnMount`: run an Effect once during the component's first render
after mount, and return its value. The Effect must be synchronous.
```tsx
const initialData = yield* Component.useOnMount(() => loadInitialData)
```
- `Component.useOnChange`: when a dependency changes, React re-renders the
component and the Effect is run again inside the component body. It returns
the latest value, so the Effect must be synchronous too.
```tsx
const label = yield* Component.useOnChange(() => formatUserLabel(id), [id])
```
- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects
that do not need to compute render output or trigger a re-render. The Effect
must be synchronous and can register scoped finalizers.
```tsx ```tsx
yield* Component.useReactEffect( yield* Component.useReactEffect(
() => Effect.forkScoped(subscribeToUser(id)), () => Effect.forkScoped(listenForNotifications(props.userId)),
[id], [props.userId],
) )
``` ```
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped When dependencies change or the component unmounts, the hook closes its scope
finalizers. and runs registered finalizers. Use `Component.useReactLayoutEffect` for the
same ownership model when setup must happen before the browser paints, such as
DOM measurement.
```tsx Component and dependency scopes debounce finalizer execution by 100
yield* Component.useReactLayoutEffect(() => measure(ref), []) milliseconds by default. This reduces unnecessary teardown during rapid
``` unmount/remount cycles, including development behavior. Configure
`finalizerExecutionDebounce` through `Component.withOptions` or the relevant
hook options when cleanup timing must differ.
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React React Strict Mode may intentionally repeat development-only render and effect
event handlers. setup. Initializers and resource acquisition should therefore be safe to run
more than once; production retains the normal component lifetime semantics.
## Run Effects from event handlers
React event handlers are plain functions. Use Effect View runners to execute an
Effect with the component's current context and scope:
```tsx ```tsx
const runPromise = yield* Component.useRunPromise() const runPromise = yield* Component.useRunPromise()
return ( return (
<button onClick={() => void runPromise(saveUser)}> <button onClick={() => void runPromise(saveUser(user))}>
Save Save
</button> </button>
) )
``` ```
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable Use `Component.useRunSync` only for Effects known to complete synchronously.
React callbacks. Use `Component.useRunPromise` for Effects that may suspend, sleep, fetch, or
otherwise continue asynchronously.
When a callback is passed to a memoized child or used as a dependency, use the
callback variants to preserve its identity:
```tsx ```tsx
const save = yield* Component.useCallbackPromise( const save = yield* Component.useCallbackPromise(
(user: User) => saveUser(user), (nextUser: User) => saveUser(nextUser),
[], [saveUser],
) )
return <button onClick={() => void save(user)}>Save</button> return <SaveButton onSave={() => void save(user)} />
``` ```
## Use React Normally `useCallbackSync` and `useCallbackPromise` follow the same dependency rules as
`React.useCallback`, while also supplying the Effect context when invoked.
An Effect View component runs as a React function component after it is bound to ## Use React normally
a runtime context. It can use regular React hooks, refs, event handlers,
context, and JSX composition. Effect View components can use regular React hooks, refs, context, event
handlers, and JSX composition:
```tsx ```tsx
import { Component } from "effect-view" import { Component } from "effect-view"
@@ -331,84 +413,77 @@ const CounterView = Component.make("Counter")(function* () {
const buttonRef = React.useRef<HTMLButtonElement>(null) const buttonRef = React.useRef<HTMLButtonElement>(null)
return ( return (
<button ref={buttonRef} onClick={() => setCount(count + 1)}> <button
ref={buttonRef}
onClick={() => setCount((count) => count + 1)}
>
Count: {count} Count: {count}
</button> </button>
) )
}) })
``` ```
Use regular React hooks for local UI concerns, and reach for Effect View helpers Prefer regular React state for simple, component-local UI concerns. Use
when the component needs Effect services, scopes, resources, or Effect-powered `Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
callbacks. sharing. The [State Management guide](./state-management) covers that model.
## Use Tags ## Provide services to a subtree
Components can yield Effect tags directly in the component body. There is no Use `Component.useLayer` when only one Effect View subtree needs extra
need to memoize the service yourself; just yield the tag wherever the component services. It builds the layer in a scope and returns the resulting Effect
needs it. context:
```tsx title="src/Greeting.tsx"
import { Component } from "effect-view"
import { GreetingService } from "./services"
const GreetingView = Component.make("Greeting")(function* (props: {
readonly name: string
}) {
const greeting = yield* GreetingService
return <p>{greeting.greet(props.name)}</p>
})
```
Service values in the runtime context are reactive by default. If a provided
service instance changes, Effect View unmounts and mounts again the components
that depend on that context, so they read the new service and restart their
scoped lifecycle with the new environment. For services that should not trigger
that behavior, pass their tags with `Component.withOptions({ nonReactiveTags:
[...] })`.
## Provide Services To A Subtree
Use `Component.useLayer` when an Effect View component should provide
extra services to another Effect View component. It turns a `Layer` into a runtime
context that can be provided to `.use`.
```tsx title="src/GreetingPageView.tsx" ```tsx title="src/GreetingPageView.tsx"
import { Effect } from "effect" import { Effect } from "effect"
import { Component } from "effect-view" import { Component } from "effect-view"
import { GreetingView } from "./Greeting" import { GreetingView } from "./GreetingView"
import { GreetingLive } from "./services" import { GreetingService } from "./GreetingService"
export const GreetingPageView = Component.make("GreetingPage")(function* () { export const GreetingPageView = Component.make("GreetingPage")(
const context = yield* Component.useLayer(GreetingLive) function* () {
const Greeting = yield* GreetingView.use.pipe(Effect.provide(context)) const context = yield* Component.useLayer(GreetingService.layer)
const Greeting = yield* GreetingView.use.pipe(
Effect.provide(context),
)
return <Greeting name="Effect" /> return <Greeting name="Effect" />
}) },
)
``` ```
The layer passed to `useLayer` should be stable. If a new layer value Keep the layer reference stable. Define static layers outside the component or
is created on every render, Effect View has to build a new context, which can memoize a layer that depends on props with `React.useMemo`; a new layer object
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers causes reconstruction and scoped cleanup.
outside the component, or memoize layers that depend on React props or state.
Layers built with `useLayer` are scoped to the component that builds Layer construction runs during render through `useOnChange`. A layer with
them. That means service construction can register scoped logic, acquire asynchronous acquisition requires the component to be enhanced with
resources, fork scoped fibers, and add finalizers that are tied to that `Async.async`. Resources built by the layer are released when the owning
component's lifecycle. component unmounts or the layer reference changes.
## Where To Go Next ## Common pitfalls
Once the runtime and component boundary are in place, the rest of the library - Effect View hooks obey React's Rules of Hooks even though they are called
builds on the same idea: with `yield*`.
- Do not yield an asynchronous Effect from a regular component body. Use
`Async.async`, Query, a Mutation callback, or a post-commit scoped fiber.
- `Component.withContext` needs a matching `ReactRuntime.Provider` above it.
- Apply `withContext` at React boundaries, not between Effect View components.
- Keep runtimes and layers stable instead of creating them on every render.
- Use `useRunPromise` rather than `useRunSync` for asynchronous event work.
- Suspense fallbacks handle waiting; React error boundaries handle failed
component Effects.
- `View.useAll` reads Effect views and rerenders when they ## Where to go next
change.
- `Lens` connects React state and Effect `SubscriptionRef` values.
- `Query` and `Mutation` model async data and user-triggered operations.
- `Form`, `MutationForm`, and `LensForm` help build Effect-backed
forms.
The important pattern is small and repeatable: write Effect View components inside - [State Management](./state-management) explains `Lens`, `View`, local state,
the runtime, then use `Component.withContext` at React boundaries. and focused state.
- [Query](./query) adds reactive keys, caching, refresh, and invalidation to
Effect-based server reads.
- [Mutation](./mutation) models user-triggered Effect operations and their
`AsyncResult` state.
- [Forms](./forms) builds schema-driven editable state with `MutationForm` and
`LensForm`.
The repeatable pattern is small: provide one runtime, define components as
Effect programs, cross into plain React with `Component.withContext`, and use
`.use` everywhere inside the Effect View tree.