Files
effect-view/packages/docs/docs/getting-started.md
T
Julien Valverdé 547aeec2fb
Lint / lint (push) Failing after 41s
Update docs
2026-07-22 03:51:26 +02:00

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:

  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

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.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.

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 AsyncResult state.
  • 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.