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
+311 -236
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.
## Component Lifecycle Use `Async.async` when render genuinely depends on an asynchronous Effect. The
component then suspends and accepts React Suspense props such as `fallback`:
Every Effect View component instance exposes an Effect `Scope`. Effects that need ```tsx title="src/UserView.tsx"
`Scope.Scope` can use that component scope to register finalizers, fork scoped import { Async, Component, Memoized } from "effect-view"
work, or acquire scoped resources. import { loadUser } from "./api"
The component scope is created when React mounts the component and closes when export const UserView = Component.make("UserView")(
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
asynchronous Effects even though the component body itself must stay
synchronous.
```tsx title="src/MountedMessageView.tsx"
import { Console, Effect } from "effect"
import { Component } from "effect-view"
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"),
)
return "This value was loaded on mount."
}),
)
return <p>{message}</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.
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.
```tsx
import { Console, Effect } from "effect"
import { Component } from "effect-view"
const UserPanelView = Component.make("UserPanel")(
function* (props: { readonly userId: string }) { function* (props: { readonly userId: string }) {
const label = yield* Component.useOnChange( const user = yield* Component.useOnChange(
() => () => loadUser(props.userId),
Effect.gen(function* () {
yield* Console.log(`Preparing view for ${props.userId}`)
yield* Effect.addFinalizer(() =>
Console.log(`Cleaning up ${props.userId}`),
)
return `Viewing user ${props.userId}`
}),
[props.userId], [props.userId],
) )
return <p>{label}</p> 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:
```tsx
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](./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 GreetingView = Component.make("Greeting")(
function* (props: { readonly name: string }) {
const greeting = yield* GreetingService
return <p>{greeting.greet(props.name)}</p>
}, },
) )
``` ```
In this example, each `userId` gets its own scope. When `userId` changes, Add `GreetingService.layer` to the application runtime, or provide it only to a
Effect View closes the previous scope, runs its finalizers, and creates a new subtree as shown later on this page.
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 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.
Unlike plain React hooks, Effect View hooks return Effects. Use `yield*` to run ## Understand lifecycle hooks
them inside the component body.
The most common Effect View hooks are: 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.
- `Component.useOnMount`: run an Effect once during the component's first render The main lifecycle choices are:
after mount, and return its value. The Effect must be synchronous.
```tsx | Need | Hook | When setup runs |
const initialData = yield* Component.useOnMount(() => loadInitialData) | --- | --- | --- |
``` | 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 |
- `Component.useOnChange`: when a dependency changes, React re-renders the ### useOnMount
component and the Effect is run again inside the component body. It returns
the latest value, so the Effect must be synchronous too.
```tsx `Component.useOnMount` computes a value during the component's initial render
const label = yield* Component.useOnChange(() => formatUserLabel(id), [id]) 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:
- `Component.useReactEffect`: Effect-powered `React.useEffect` for side effects ```tsx title="src/LocalStateView.tsx"
that do not need to compute render output or trigger a re-render. The Effect import { Effect, SubscriptionRef } from "effect"
must be synchronous and can register scoped finalizers. import { Component, Lens, View } from "effect-view"
```tsx export const LocalStateView = Component.make("LocalState")(
yield* Component.useReactEffect( function* () {
() => Effect.forkScoped(subscribeToUser(id)), const count = yield* Component.useOnMount(() =>
[id], 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>
},
) )
``` ```
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped In a regular component, the setup Effect must be synchronous because it runs
finalizers. 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
yield* Component.useReactLayoutEffect(() => measure(ref), []) const label = yield* Component.useOnChange(
() =>
Effect.gen(function* () {
yield* Effect.addFinalizer(() =>
Effect.log(`Stopped viewing ${props.userId}`),
)
return `Viewing user ${props.userId}`
}),
[props.userId],
)
``` ```
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React Dependency arrays follow normal React semantics. Include every reactive value
event handlers. read by the setup Effect.
### useReactEffect and useReactLayoutEffect
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(listenForNotifications(props.userId)),
[props.userId],
)
```
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.
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.
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 ```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.