Improve docs
Lint / lint (push) Failing after 42s

This commit is contained in:
Julien Valverdé
2026-07-22 03:23:16 +02:00
parent c4b954d21c
commit 36c47e1b4b
+297 -222
View File
@@ -5,50 +5,40 @@ title: Getting Started
# Getting Started
`effect-view` lets React components be written as Effect programs. Inside a
component body you can yield services, run Effects, subscribe to Effect-powered
state, and still export a normal React function component at the edge of your
app.
`effect-view` lets a React function component be described as an Effect
program. Inside a component you can yield services, create scoped resources,
subscribe to Effect-powered state, and turn Effects into React callbacks. At a
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 `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
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
npm install react-dom
```
```bash npm2yarn
npm install --save-dev @types/react-dom
npm install --save-dev @types/react @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
needs, then share it with React through `ReactRuntime.Provider`.
## Create the runtime
For an empty app, `Layer.empty` is enough:
```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:
`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"
@@ -62,9 +52,23 @@ const AppLive = Layer.empty.pipe(
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"
import { StrictMode } from "react"
@@ -75,47 +79,52 @@ import { runtime } from "./runtime"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider runtime={runtime}>
<ReactRuntime.Provider
runtime={runtime}
fallback={<p>Starting application...</p>}
>
<App />
</ReactRuntime.Provider>
</StrictMode>,
)
```
`ReactRuntime.Provider` also works with routers. Keep it above your router
provider so route components can use the same runtime context.
With a router, keep the provider above the router provider:
## 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
`Component.makeUntraced` when you only want the component behavior. This creates
an Effect View component, not a plain React component yet.
If runtime layer construction can fail, place an appropriate React error
boundary above `ReactRuntime.Provider` as well.
The Effect run during render must be synchronous. Effect View follows React's
render model, so component bodies should produce JSX without waiting on async
work. Use lifecycle hooks, callbacks, queries, or other Effect View helpers for
async work that happens outside render.
## Write your first component
`Component.make` defines a component body using the same generator style as
`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"
import { Effect } from "effect"
import { Component } from "effect-view"
export const HelloView = Component.make("HelloView")(function* (props: {
readonly name: string
}) {
export const HelloView = Component.make("HelloView")(
function* (props: { readonly name: string }) {
const message = yield* Effect.succeed(`Hello, ${props.name}`)
return <h1>{message}</h1>
})
},
)
```
At this point `HelloView` can yield Effects, services, and scoped lifecycle
work, but React cannot render it directly.
Use `Component.makeUntraced("HelloView")` when you want the display name but do
not want an automatic tracing span.
## Apply A Runtime
Use `Component.withContext` at the boundary where an Effect View component needs
to become a regular React function component.
`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
library needs a function component:
```tsx title="src/Hello.tsx"
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"
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
their `.use` effect. Yield `.use` to get a React component for the current
runtime context, then render it like JSX.
## Compose Effect View components
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"
import { Component } from "effect-view"
@@ -154,173 +166,243 @@ export const GreetingCardView = Component.make("GreetingCard")(
return (
<section>
<Hello name="Effect" />
<p>This component is still running inside Effect View.</p>
<p>Both components use the same Effect context.</p>
</section>
)
},
)
```
Use `Component.withContext` only when you leave the Effect View tree and need a
normal React component again:
Only apply `Component.withContext` when crossing from Effect View into plain
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"
import { Component } from "effect-view"
import { GreetingCardView } from "./GreetingCardView"
import { runtime } from "./runtime"
## Synchronous and asynchronous components
export const GreetingCard = GreetingCardView.pipe(
Component.withContext(runtime.context),
A regular Effect View component runs its body during React render. That Effect
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
`Scope.Scope` can use that component scope to register finalizers, fork scoped
work, or acquire scoped resources.
```tsx
const User = yield* UserView.use
The component scope is created when React mounts the component and closes when
React unmounts it. When the scope closes, Effect runs the finalizers registered
inside that scope.
return <User userId={selectedUserId} />
```
Finalizers are forked when the scope closes, so cleanup logic can run
asynchronous Effects even though the component body itself must stay
synchronous.
`Memoized.memoized` prevents an unrelated parent re-render from restarting an
async child whose props have not changed. A changed `userId` creates a new
dependency scope, cleans up the previous one, and runs `loadUser` again.
```tsx title="src/MountedMessageView.tsx"
import { Console, Effect } from "effect"
An async component's rejected Effect is handled by the nearest React error
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 { GreetingService } from "./GreetingService"
export const MountedMessageView = Component.make("MountedMessage")(
function* () {
const message = yield* Component.useOnMount(() =>
Effect.gen(function* () {
yield* Console.log("MountedMessage mounted")
yield* Effect.addFinalizer(() =>
Console.log("MountedMessage unmounted"),
)
export const GreetingView = Component.make("Greeting")(
function* (props: { readonly name: string }) {
const greeting = yield* GreetingService
return "This value was loaded on mount."
}),
)
return <p>{message}</p>
return <p>{greeting.greet(props.name)}</p>
},
)
```
Use `Component.useOnMount` when the component needs a value produced by an
Effect during its first render. The value is then cached for the component
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.
Add `GreetingService.layer` to the application runtime, or provide it only to a
subtree as shown later on this page.
Use `Component.useOnChange` when render needs the value computed by
scoped work that depends on changing inputs. When a dependency changes, React
re-renders the component and the hook computes the next value during that
render.
Effect service instances are reactive at component boundaries. If the supplied
context changes to contain a different service instance, dependent Effect View
components are recreated so they read the new environment and restart their
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
import { Console, Effect } from "effect"
import { Component } from "effect-view"
const UserPanelView = Component.make("UserPanel")(
function* (props: { readonly userId: string }) {
const label = yield* Component.useOnChange(
const label = yield* Component.useOnChange(
() =>
Effect.gen(function* () {
yield* Console.log(`Preparing view for ${props.userId}`)
yield* Effect.addFinalizer(() =>
Console.log(`Cleaning up ${props.userId}`),
Effect.log(`Stopped viewing ${props.userId}`),
)
return `Viewing user ${props.userId}`
}),
[props.userId],
)
return <p>{label}</p>
},
)
```
In this example, each `userId` gets its own scope. When `userId` changes,
Effect View closes the previous scope, runs its finalizers, and creates a new
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.
Dependency arrays follow normal React semantics. Include every reactive value
read by the setup Effect.
## Useful Effect View Hooks
### useReactEffect and useReactLayoutEffect
Unlike plain React hooks, Effect View hooks return Effects. Use `yield*` to run
them inside the component body.
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.
Use `Component.useReactEffect` for work that should start only after React has
committed the render. Setup must complete synchronously, but it can fork
asynchronous work into the provided scope:
```tsx
yield* Component.useReactEffect(
() => Effect.forkScoped(subscribeToUser(id)),
[id],
() => Effect.forkScoped(listenForNotifications(props.userId)),
[props.userId],
)
```
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped
finalizers.
When dependencies change or the component unmounts, the hook closes its scope
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
yield* Component.useReactLayoutEffect(() => measure(ref), [])
```
Component and dependency scopes debounce finalizer execution by 100
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
event handlers.
React Strict Mode may intentionally repeat development-only render and effect
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
const runPromise = yield* Component.useRunPromise()
return (
<button onClick={() => void runPromise(saveUser)}>
<button onClick={() => void runPromise(saveUser(user))}>
Save
</button>
)
```
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable
React callbacks.
Use `Component.useRunSync` only for Effects known to complete synchronously.
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
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
a runtime context. It can use regular React hooks, refs, event handlers,
context, and JSX composition.
## Use React normally
Effect View components can use regular React hooks, refs, context, event
handlers, and JSX composition:
```tsx
import { Component } from "effect-view"
@@ -331,84 +413,77 @@ const CounterView = Component.make("Counter")(function* () {
const buttonRef = React.useRef<HTMLButtonElement>(null)
return (
<button ref={buttonRef} onClick={() => setCount(count + 1)}>
<button
ref={buttonRef}
onClick={() => setCount((count) => count + 1)}
>
Count: {count}
</button>
)
})
```
Use regular React hooks for local UI concerns, and reach for Effect View helpers
when the component needs Effect services, scopes, resources, or Effect-powered
callbacks.
Prefer regular React state for simple, component-local UI concerns. Use
`Lens`/`View` when state needs Effect integration, subscriptions, focusing, or
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
need to memoize the service yourself; just yield the tag wherever the component
needs it.
```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`.
Use `Component.useLayer` when only one Effect View subtree needs extra
services. It builds the layer in a scope and returns the resulting Effect
context:
```tsx title="src/GreetingPageView.tsx"
import { Effect } from "effect"
import { Component } from "effect-view"
import { GreetingView } from "./Greeting"
import { GreetingLive } from "./services"
import { GreetingView } from "./GreetingView"
import { GreetingService } from "./GreetingService"
export const GreetingPageView = Component.make("GreetingPage")(function* () {
const context = yield* Component.useLayer(GreetingLive)
const Greeting = yield* GreetingView.use.pipe(Effect.provide(context))
export const GreetingPageView = Component.make("GreetingPage")(
function* () {
const context = yield* Component.useLayer(GreetingService.layer)
const Greeting = yield* GreetingView.use.pipe(
Effect.provide(context),
)
return <Greeting name="Effect" />
})
},
)
```
The layer passed to `useLayer` should be stable. If a new layer value
is created on every render, Effect View has to build a new context, which can
cause unnecessary re-renders and scoped lifecycle restarts. Define static layers
outside the component, or memoize layers that depend on React props or state.
Keep the layer reference stable. Define static layers outside the component or
memoize a layer that depends on props with `React.useMemo`; a new layer object
causes reconstruction and scoped cleanup.
Layers built with `useLayer` are scoped to the component that builds
them. That means service construction can register scoped logic, acquire
resources, fork scoped fibers, and add finalizers that are tied to that
component's lifecycle.
Layer construction runs during render through `useOnChange`. A layer with
asynchronous acquisition requires the component to be enhanced with
`Async.async`. Resources built by the layer are released when the owning
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
builds on the same idea:
- Effect View hooks obey React's Rules of Hooks even though they are called
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
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.
## Where to go next
The important pattern is small and repeatable: write Effect View components inside
the runtime, then use `Component.withContext` at React boundaries.
- [State Management](./state-management) explains `Lens`, `View`, local state,
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.