diff --git a/packages/docs/docs/getting-started.md b/packages/docs/docs/getting-started.md index e941385..ee87910 100644 --- a/packages/docs/docs/getting-started.md +++ b/packages/docs/docs/getting-started.md @@ -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( - + Starting application...

} + >
, ) ``` -`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 +Starting...

}> + +
+``` -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 -}) { - const message = yield* Effect.succeed(`Hello, ${props.name}`) +export const HelloView = Component.make("HelloView")( + function* (props: { readonly name: string }) { + const message = yield* Effect.succeed(`Hello, ${props.name}`) - return

{message}

-}) + return

{message}

+ }, +) ``` -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 (
-

This component is still running inside Effect View.

+

Both components use the same Effect context.

) }, ) ``` -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. -## 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 -`Scope.Scope` can use that component scope to register finalizers, fork scoped -work, or acquire scoped resources. +```tsx title="src/UserView.tsx" +import { Async, Component, Memoized } from "effect-view" +import { loadUser } from "./api" -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. - -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

{message}

- }, -) -``` - -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")( +export const UserView = Component.make("UserView")( function* (props: { readonly userId: string }) { - 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}`), - ) - - return `Viewing user ${props.userId}` - }), + const user = yield* Component.useOnChange( + () => loadUser(props.userId), [props.userId], ) - return

{label}

+ return

{user.name}

+ }, +).pipe( + Async.async, + Async.withOptions({ defaultFallback:

Loading user...

}), + Memoized.memoized, +) +``` + +Use it from an Effect View parent in the usual way: + +```tsx +const User = yield* UserView.use + +return +``` + +`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

{greeting.greet(props.name)}

}, ) ``` -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. +Add `GreetingService.layer` to the application runtime, or provide it only to a +subtree as shown later on this page. -## 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 -them inside the component body. +## Understand lifecycle hooks -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 - after mount, and return its value. The Effect must be synchronous. +The main lifecycle choices are: -```tsx -const initialData = yield* Component.useOnMount(() => loadInitialData) -``` +| 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 | -- `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. +### useOnMount -```tsx -const label = yield* Component.useOnChange(() => formatUserLabel(id), [id]) -``` +`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: -- `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 title="src/LocalStateView.tsx" +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens, View } from "effect-view" -```tsx -yield* Component.useReactEffect( - () => Effect.forkScoped(subscribeToUser(id)), - [id], +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

Count: {value}

+ }, ) ``` -- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped - finalizers. +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 -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 - event handlers. +Dependency arrays follow normal React semantics. Include every reactive value +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 const runPromise = yield* Component.useRunPromise() return ( - ) ``` -- `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 +return 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(null) return ( - ) }) ``` -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

{greeting.greet(props.name)}

-}) -``` - -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 -}) + return + }, +) ``` -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.