Add Effect v4 support, Fast Refresh tooling, and revamped docs (#56)
## Summary - Add `effect-fc-next`, a React 19 integration targeting Effect v4 beta. - Introduce component lifecycles, scoped resources, queries, mutations, forms, lenses, views, and refreshable components. - Add `@effect-view/vite-plugin` for Vite Fast Refresh support. - Add an Effect v4 example application covering the new APIs. - Expand test coverage for both the existing and next-generation packages. - Replace the starter Docusaurus content with complete Effect View documentation while preserving the Effect v3 docs as a versioned snapshot. - Update the landing page, navigation, package scripts, and build output. - Extend CI with linting, tests, package builds, Docker builds, and container publishing. ## Validation - `bun lint:tsc` - `bun lint:biome` - `bun test` - `bun run build` - `bun pack` - Docker image build --------- Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: Thilawyn/effect-fc#56
This commit was merged in pull request #56.
This commit is contained in:
@@ -0,0 +1,415 @@
|
||||
---
|
||||
sidebar_position: 1
|
||||
title: Getting Started
|
||||
---
|
||||
|
||||
# Getting Started
|
||||
|
||||
`effect-fc` 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.
|
||||
|
||||
## Install
|
||||
|
||||
Install `effect-fc` alongside `effect` and React 19.2 or newer:
|
||||
|
||||
```bash npm2yarn
|
||||
npm install effect-fc effect react
|
||||
```
|
||||
```bash npm2yarn
|
||||
npm install --save-dev @types/react
|
||||
```
|
||||
|
||||
`effect-fc` 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
|
||||
```
|
||||
|
||||
## Create A Runtime
|
||||
|
||||
An Effect-FC app needs an Effect runtime. Build one from the services your UI
|
||||
needs, then share it with React through `ReactRuntime.Provider`.
|
||||
|
||||
For an empty app, `Layer.empty` is enough:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
export const runtime = ReactRuntime.make(Layer.empty)
|
||||
```
|
||||
|
||||
As your app grows, add services to the layer:
|
||||
|
||||
```tsx title="src/runtime.ts"
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { Layer } from "effect"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
|
||||
const AppLive = Layer.empty.pipe(
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
export const runtime = ReactRuntime.make(AppLive)
|
||||
```
|
||||
|
||||
## Provide The Runtime
|
||||
|
||||
At the React root, wrap your app with `ReactRuntime.Provider`:
|
||||
|
||||
```tsx title="src/main.tsx"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { ReactRuntime } from "effect-fc"
|
||||
import { App } from "./App"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReactRuntime.Provider runtime={runtime}>
|
||||
<App />
|
||||
</ReactRuntime.Provider>
|
||||
</StrictMode>,
|
||||
)
|
||||
```
|
||||
|
||||
`ReactRuntime.Provider` also works with routers. Keep it above your router
|
||||
provider so route components can be converted with the same runtime context.
|
||||
|
||||
## Write Your First Component
|
||||
|
||||
Use `Component.make` when you want automatic tracing spans, or
|
||||
`Component.makeUntraced` when you only want the component behavior. This creates
|
||||
an Effect-FC component, not a plain React component yet.
|
||||
|
||||
The Effect run during render must be synchronous. Effect-FC follows React's
|
||||
render model, so component bodies should produce JSX without waiting on async
|
||||
work. Use lifecycle hooks, callbacks, queries, or other Effect-FC helpers for
|
||||
async work that happens outside render.
|
||||
|
||||
```tsx title="src/HelloView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
|
||||
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.
|
||||
|
||||
## Apply A Runtime
|
||||
|
||||
Use `Component.withRuntime` at the boundary where an Effect-FC component needs
|
||||
to become a regular React function component.
|
||||
|
||||
```tsx title="src/Hello.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { HelloView } from "./HelloView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const Hello = HelloView.pipe(
|
||||
Component.withRuntime(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
`Hello` is now a normal React component:
|
||||
|
||||
```tsx title="src/App.tsx"
|
||||
import { Hello } from "./Hello"
|
||||
|
||||
export function App() {
|
||||
return <Hello name="Effect" />
|
||||
}
|
||||
```
|
||||
|
||||
## Use Effect-FC Components Together
|
||||
|
||||
Inside an Effect-FC component, other Effect-FC components are available through
|
||||
their `.use` effect. Yield `.use` to get a React component for the current
|
||||
runtime context, then render it like JSX.
|
||||
|
||||
```tsx title="src/GreetingCardView.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { HelloView } from "./HelloView"
|
||||
|
||||
export const GreetingCardView = Component.make("GreetingCard")(
|
||||
function* () {
|
||||
const Hello = yield* HelloView.use
|
||||
|
||||
return (
|
||||
<section>
|
||||
<Hello name="Effect" />
|
||||
<p>This component is still running inside Effect-FC.</p>
|
||||
</section>
|
||||
)
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
Use `Component.withRuntime` only when you leave the Effect-FC tree and need a
|
||||
normal React component again:
|
||||
|
||||
```tsx title="src/GreetingCard.tsx"
|
||||
import { Component } from "effect-fc"
|
||||
import { GreetingCardView } from "./GreetingCardView"
|
||||
import { runtime } from "./runtime"
|
||||
|
||||
export const GreetingCard = GreetingCardView.pipe(
|
||||
Component.withRuntime(runtime.context),
|
||||
)
|
||||
```
|
||||
|
||||
## Component Lifecycle
|
||||
|
||||
Every Effect-FC 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.
|
||||
|
||||
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-fc"
|
||||
|
||||
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-fc"
|
||||
|
||||
const UserPanelView = Component.make("UserPanel")(
|
||||
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}`
|
||||
}),
|
||||
[props.userId],
|
||||
)
|
||||
|
||||
return <p>{label}</p>
|
||||
},
|
||||
)
|
||||
```
|
||||
|
||||
In this example, each `userId` gets its own scope. When `userId` changes,
|
||||
Effect-FC 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-FC hooks follow the same pattern when they
|
||||
need a lifecycle that is narrower than the whole component instance.
|
||||
|
||||
## Useful Effect-FC Hooks
|
||||
|
||||
Unlike plain React hooks, Effect-FC hooks return Effects. Use `yield*` to run
|
||||
them inside the component body.
|
||||
|
||||
The most common Effect-FC 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.
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactEffect(
|
||||
() => Effect.forkScoped(subscribeToUser(id)),
|
||||
[id],
|
||||
)
|
||||
```
|
||||
|
||||
- `Component.useReactLayoutEffect`: Effect-powered `React.useLayoutEffect` with scoped
|
||||
finalizers.
|
||||
|
||||
```tsx
|
||||
yield* Component.useReactLayoutEffect(() => measure(ref), [])
|
||||
```
|
||||
|
||||
- `Component.useRunSync` and `Component.useRunPromise`: run Effects from React
|
||||
event handlers.
|
||||
|
||||
```tsx
|
||||
const runPromise = yield* Component.useRunPromise()
|
||||
|
||||
return (
|
||||
<button onClick={() => void runPromise(saveUser)}>
|
||||
Save
|
||||
</button>
|
||||
)
|
||||
```
|
||||
|
||||
- `Component.useCallbackSync` and `Component.useCallbackPromise`: create stable
|
||||
React callbacks.
|
||||
|
||||
```tsx
|
||||
const save = yield* Component.useCallbackPromise(
|
||||
(user: User) => saveUser(user),
|
||||
[],
|
||||
)
|
||||
|
||||
return <button onClick={() => void save(user)}>Save</button>
|
||||
```
|
||||
|
||||
## Use React Normally
|
||||
|
||||
An Effect-FC component is still a React function component. Anything you can do
|
||||
in a regular React component can also be done in an Effect-FC component,
|
||||
including React hooks, refs, event handlers, context, and JSX composition.
|
||||
|
||||
```tsx
|
||||
import { Component } from "effect-fc"
|
||||
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 + 1)}>
|
||||
Count: {count}
|
||||
</button>
|
||||
)
|
||||
})
|
||||
```
|
||||
|
||||
Use regular React hooks for local UI concerns, and reach for Effect-FC helpers
|
||||
when the component needs Effect services, scopes, resources, or Effect-powered
|
||||
callbacks.
|
||||
|
||||
## Use Tags
|
||||
|
||||
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"
|
||||
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-FC 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.useContextFromLayer` when an Effect-FC component should provide
|
||||
extra services to another Effect-FC component. It turns a `Layer` into a runtime
|
||||
context that can be provided to `.use`.
|
||||
|
||||
```tsx title="src/GreetingPageView.tsx"
|
||||
import { Effect } from "effect"
|
||||
import { Component } from "effect-fc"
|
||||
import { GreetingView } from "./Greeting"
|
||||
import { GreetingService } from "./services"
|
||||
|
||||
const GreetingLive = GreetingService.Default({
|
||||
greet: (name: string) => `Welcome, ${name}`,
|
||||
})
|
||||
|
||||
export const GreetingPageView = Component.make("GreetingPage")(function* () {
|
||||
const context = yield* Component.useContextFromLayer(GreetingLive)
|
||||
const Greeting = yield* Effect.provide(GreetingView.use, context)
|
||||
|
||||
return <Greeting name="Effect" />
|
||||
})
|
||||
```
|
||||
|
||||
The layer passed to `useContextFromLayer` should be stable. If a new layer value
|
||||
is created on every render, Effect-FC 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.
|
||||
|
||||
Layers built with `useContextFromLayer` 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.
|
||||
|
||||
## Where To Go Next
|
||||
|
||||
Once the runtime and component boundary are in place, the rest of the library
|
||||
builds on the same idea:
|
||||
|
||||
- `Subscribable.useAll` reads Effect subscribables and rerenders when they
|
||||
change.
|
||||
- `Lens` connects React state and Effect `SubscriptionRef` values.
|
||||
- `Query` and `Mutation` model async data and user-triggered operations.
|
||||
- `Form`, `SubmittableForm`, and `SynchronizedForm` help build Effect-backed
|
||||
forms.
|
||||
|
||||
The important pattern is small and repeatable: write Effect-FC components inside
|
||||
the runtime, then use `Component.withRuntime` at React boundaries.
|
||||
Reference in New Issue
Block a user