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 22933b2a09 - Show all commits
+78 -16
View File
@@ -271,20 +271,76 @@ 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:
```tsx
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.
Other lifecycle hooks need a cleanup boundary narrower than the whole
component, so they create a new scope and provide it to their setup Effect:
```text
React component instance
└── Component root scope
├── Component body uses the root scope
├── useOnMount setup uses the root scope
├── useOnChange setup for dependencies A gets its own scope
├── useReactEffect setup gets its own scope
└── useReactLayoutEffect setup gets its own scope
```
The narrower scopes are managed by their hooks rather than being implicit
Effect child scopes. React unmounting closes all of them; dependency changes
can close and replace a hook scope without closing the component root scope.
The main lifecycle choices are:
| Need | Hook | When setup runs |
| Hook | Scope seen by setup | Scope closes when |
| --- | --- | --- |
| 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 body | Component root scope | The component unmounts |
| `useOnMount` | The same component root scope | The component unmounts |
| `useOnChange` | A new dependency scope | Dependencies change or the component unmounts |
| `useReactEffect` | A new effect scope | Dependencies change or the component unmounts |
| `useReactLayoutEffect` | A new layout-effect scope | Dependencies change or the component unmounts |
| `useLayer` | 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 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:
and returns the cached value on later renders. It does not install a scope of
its own: its Effect inherits the root scope provided to the component body.
Acquired resources and forked fibers are therefore tied to the full component
lifetime:
```tsx title="src/LocalStateView.tsx"
import { Effect, SubscriptionRef } from "effect"
@@ -317,8 +373,9 @@ 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:
for one dependency window. It creates that scope and provides it to the setup
Effect instead of the component root scope. When a dependency changes, the
previous dependency scope is closed and a new value is computed:
```tsx
const label = yield* Component.useOnChange(
@@ -340,8 +397,9 @@ 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:
committed the render. The hook creates a fresh scope, provides it to the setup
Effect, and closes it through React effect cleanup. Setup must complete
synchronously, but it can fork asynchronous work into that scope:
```tsx
yield* Component.useReactEffect(
@@ -355,11 +413,15 @@ 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.
The component root scope and scopes created by `useOnChange` debounce finalizer
execution by 100 milliseconds by default. This reduces unnecessary teardown
during rapid unmount/remount cycles, including development behavior. Configure
the root through `Component.withOptions({ finalizerExecutionDebounce: ... })`,
or pass `finalizerExecutionDebounce` in the `useOnChange` options.
`useReactEffect` and `useReactLayoutEffect` instead close their scopes directly
from React cleanup. Their `finalizerExecutionMode` option controls whether that
close runs synchronously or is forked; it defaults to `"fork"`.
React Strict Mode may intentionally repeat development-only render and effect
setup. Initializers and resource acquisition should therefore be safe to run