16 KiB
sidebar_position, title
| sidebar_position | title |
|---|---|
| 1 | Getting Started |
Getting Started
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:
ReactRuntimebuilds the Effect services available to the UI.Component.makedefines an Effect View component.Component.withContextconverts an Effect View component into a normal React component at an application or router boundary.- Effect View children are composed through their
.useEffect.
Install
For a web application, install Effect View with Effect 4 and React 19.2 or newer:
npm install effect-view effect@beta react react-dom
npm install --save-dev @types/react @types/react-dom
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.
Create the runtime
ReactRuntime owns a managed Effect runtime. Define it at module scope from the
layers needed by the UI:
import { Layer } from "effect"
import { FetchHttpClient } from "effect/unstable/http"
import { ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(FetchHttpClient.layer),
)
export const runtime = ReactRuntime.make(AppLive)
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:
import { StrictMode } from "react"
import { createRoot } from "react-dom/client"
import { ReactRuntime } from "effect-view"
import { App } from "./App"
import { runtime } from "./runtime"
createRoot(document.getElementById("root")!).render(
<StrictMode>
<ReactRuntime.Provider
runtime={runtime}
fallback={<p>Starting application...</p>}
>
<App />
</ReactRuntime.Provider>
</StrictMode>,
)
With a router, keep the provider above the router provider:
<ReactRuntime.Provider runtime={runtime} fallback={<p>Starting...</p>}>
<RouterProvider router={router} />
</ReactRuntime.Provider>
If runtime layer construction can fail, place an appropriate React error
boundary above ReactRuntime.Provider as well.
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:
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>
},
)
Use Component.makeUntraced("HelloView") when you want the display name but do
not want an automatic tracing span.
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:
import { Component } from "effect-view"
import { HelloView } from "./HelloView"
import { runtime } from "./runtime"
export const Hello = HelloView.pipe(
Component.withContext(runtime.context),
)
It can now be rendered by ordinary React:
import { Hello } from "./Hello"
export function App() {
return <Hello name="Effect" />
}
Component.withContext reads the context populated by the matching
ReactRuntime.Provider. It does not build or provide the runtime by itself.
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:
import { Component } from "effect-view"
import { HelloView } from "./HelloView"
export const GreetingCardView = Component.make("GreetingCard")(
function* () {
const Hello = yield* HelloView.use
return (
<section>
<Hello name="Effect" />
<p>Both components use the same Effect context.</p>
</section>
)
},
)
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.
Synchronous and asynchronous components
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:
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,
)
Use it from an Effect View parent in the usual way:
const User = yield* UserView.use
return <User userId={selectedUserId} />
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.
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 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:
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}`,
})
}
import { Component } from "effect-view"
import { GreetingService } from "./GreetingService"
export const GreetingView = Component.make("Greeting")(
function* (props: { readonly name: string }) {
const greeting = yield* GreetingService
return <p>{greeting.greet(props.name)}</p>
},
)
Add GreetingService.layer to the application runtime, or provide it only to a
subtree as shown later on this page.
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 component root scope
Every rendered Effect View component gets a root Scope.Scope. Effect View
creates it when the component instance is rendered, provides it to the entire
component body, and closes it when that React component unmounts.
Effects yielded directly from the body therefore see the root scope. This also
means that Effect.addFinalizer, Effect.acquireRelease, and
Effect.forkScoped can be used naturally inside component setup:
import { Effect } from "effect"
import { Component } from "effect-view"
const ResourceView = Component.make("Resource")(function* () {
const resource = yield* Component.useOnMount(() =>
Effect.acquireRelease(
openResource,
(resource) => closeResource(resource),
),
)
return <p>{resource.name}</p>
})
useOnMount does not create or provide another scope. It runs its Effect
with the context already available in the component body, so the resource above
is owned by the component's root scope.
useOnChange, useReactEffect, and useReactLayoutEffect each create a scope
that can be replaced without closing the component root scope.
The main lifecycle choices are:
| Hook | What it does | Scope seen by setup | Scope closes when |
|---|---|---|---|
| Component body | Produces the component's rendered value | Component root scope | The component unmounts |
useOnMount |
Computes and caches a value for the component instance | The same component root scope | The component unmounts |
useOnChange |
Recomputes a cached value when dependencies change | A new dependency scope | Dependencies change or the component unmounts |
useReactEffect |
Runs a post-commit side effect | A new effect scope | Dependencies change or the component unmounts |
useReactLayoutEffect |
Runs a layout effect before the browser paints | A new layout-effect scope | Dependencies change or the component unmounts |
useLayer |
Builds and provides a layer context | A dependency scope created through useOnChange |
The layer reference changes or the component unmounts |
useRunSync, useRunPromise, useCallbackSync, and useCallbackPromise do
not create lifecycle scopes either. They capture the component context, so an
Effect invoked through them sees the component root scope unless it explicitly
provides another one.
useOnMount
Component.useOnMount computes a value during the initial render and caches it
for later renders. It uses the component root scope, so its resources live
until the component unmounts.
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 computes and caches a value, then recomputes it when its
dependencies change. Each dependency set gets its own scope, which closes when
the dependencies change or the component unmounts.
const label = yield* Component.useOnChange(
() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.log(`Stopped viewing ${props.userId}`),
)
return `Viewing user ${props.userId}`
}),
[props.userId],
)
Dependency arrays follow normal React semantics. Include every reactive value read by the setup Effect.
useReactEffect and useReactLayoutEffect
Component.useReactEffect runs side effects after React commits, while
Component.useReactLayoutEffect runs before the browser paints. Each hook owns
a scope that closes when its dependencies change or the component unmounts.
Setup must complete synchronously, but it can fork asynchronous work into the hook scope:
yield* Component.useReactEffect(
() => Effect.forkScoped(listenForNotifications(props.userId)),
[props.userId],
)
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:
const runPromise = yield* Component.useRunPromise()
return (
<button onClick={() => void runPromise(saveUser(user))}>
Save
</button>
)
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:
const save = yield* Component.useCallbackPromise(
(nextUser: User) => saveUser(nextUser),
[saveUser],
)
return <SaveButton onSave={() => void save(user)} />
useCallbackSync and useCallbackPromise follow the same dependency rules as
React.useCallback, while also supplying the Effect context when invoked.
Use React normally
Effect View components can use regular React hooks, refs, context, event handlers, and JSX composition:
import { Component } from "effect-view"
import * as React from "react"
const CounterView = Component.make("Counter")(function* () {
const [count, setCount] = React.useState(0)
const buttonRef = React.useRef<HTMLButtonElement>(null)
return (
<button
ref={buttonRef}
onClick={() => setCount((count) => count + 1)}
>
Count: {count}
</button>
)
})
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 covers that model.
Provide services to a subtree
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:
import { Effect } from "effect"
import { Component } from "effect-view"
import { GreetingView } from "./GreetingView"
import { GreetingService } from "./GreetingService"
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" />
},
)
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.
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.
Common pitfalls
- 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.withContextneeds a matchingReactRuntime.Providerabove it.- Apply
withContextat React boundaries, not between Effect View components. - Keep runtimes and layers stable instead of creating them on every render.
- Use
useRunPromiserather thanuseRunSyncfor asynchronous event work. - Suspense fallbacks handle waiting; React error boundaries handle failed component Effects.
Where to go next
- State Management explains
Lens,View, local state, and focused state. - Query adds reactive keys, caching, refresh, and invalidation to Effect-based server reads.
- Mutation models user-triggered Effect operations and their
AsyncResultstate. - Forms builds schema-driven editable state with
MutationFormandLensForm.
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.