Improve AI docs
Lint / lint (push) Successful in 25s

This commit is contained in:
Julien Valverdé
2026-08-24 03:02:24 +02:00
parent 9e24b131b1
commit 01e62b7ae7
8 changed files with 83 additions and 100 deletions
+25 -21
View File
@@ -4,7 +4,7 @@
Requires Effect v4 (RC) and React 19.2+. Peer dependencies: `effect`, `react`, `@types/react`. Not tied to `react-dom` — any React renderer works.
When writing effect-view code, use the actual current source and tests in `src/` as ground truth over anything remembered from training — the API is pre-1.0 and still moving. The files below are a concise reference to how each module fits together; read the linked one(s) before writing code that uses that module.
When writing effect-view code, use the actual current source and tests in `src/` as ground truth over anything remembered from training — the API is pre-1.0 and still moving. The files below are a concise reference; read the linked one(s) before writing code that touches that concern.
## Core model
@@ -14,27 +14,31 @@ When writing effect-view code, use the actual current source and tests in `src/`
4. Inside another effect-view component, compose children by yielding their `.use` Effect.
5. Every rendered component instance owns a root `Scope.Scope`, opened on mount and closed on unmount; regular component setup must complete **synchronously** during render unless the component is wrapped with `Async.async`.
## Module index
## Find the right doc by what you're trying to do
| Module | Covers |
|---|---|
| [ReactRuntime.md](./ai-docs/ReactRuntime.md) | building and providing the application's Effect runtime |
| [Component.md](./ai-docs/Component.md) | defining components, lifecycle hooks, running Effects from event handlers, providing services |
| [Async.md](./ai-docs/Async.md) | components that suspend on an asynchronous Effect before rendering |
| [Memoized.md](./ai-docs/Memoized.md) | skipping re-render/re-computation via `React.memo` |
| [Lens.md](./ai-docs/Lens.md) | the core read/write state primitive, focusing, where to store state |
| [View.md](./ai-docs/View.md) | the read-only side of state, `View.useAll` |
| [Query.md](./ai-docs/Query.md) | cached, reactive, TanStack-Query-style server reads |
| [QueryClient.md](./ai-docs/QueryClient.md) | the shared cache service backing `Query` |
| [Mutation.md](./ai-docs/Mutation.md) | user-triggered writes with pending/error state |
| [Form.md](./ai-docs/Form.md) | the shared schema-driven form model and input hooks |
| [MutationForm.md](./ai-docs/MutationForm.md) | forms that submit a valid value to a `Mutation` |
| [LensForm.md](./ai-docs/LensForm.md) | forms that keep a target `Lens` synchronized with a valid draft |
| [PubSub.md](./ai-docs/PubSub.md) | bridging React-tracked values into an Effect `PubSub` |
| [Stream.md](./ai-docs/Stream.md) | consuming a raw Effect `Stream` as React state |
| [SetStateAction.md](./ai-docs/SetStateAction.md) | resolving `React.SetStateAction` values |
| [ScopeRegistry.md](./ai-docs/ScopeRegistry.md) | internal — component scope lifecycle bookkeeping |
| [Refreshable.md](./ai-docs/Refreshable.md) | internal — hot-reload integration for dev-server tooling |
**Setting up the app** — building the runtime, providing it to the tree → [ReactRuntime.md](./ai-docs/ReactRuntime.md)
**Defining a component, its lifecycle, or running Effects from event handlers** → [Component.md](./ai-docs/Component.md)
**Storing, reading, or subscribing to state** (local, shared via a service, or focused into a nested field) → [State.md](./ai-docs/State.md) — `Lens` (read/write) and `View` (read-only)
**Rendering something that needs to wait on an async Effect, or avoiding unnecessary re-renders/re-fetches** [Async.md](./ai-docs/Async.md) `Async` (suspend on an Effect) and `Memoized` (`React.memo` wrapper)
**Fetching/caching server data** (reactive keys, staleness, background refresh, invalidation) → [Query.md](./ai-docs/Query.md) — includes `QueryClient`, the cache service `Query` runs against
**Triggering a write** (save, delete, upload, send) with pending/error state → [Mutation.md](./ai-docs/Mutation.md)
**Building a schema-driven form**:
- shared concepts (encoded vs decoded value, focusing into fields, input/status hooks) → [Form.md](./ai-docs/Form.md) — read this first
- a form that submits a valid value via a `Mutation` → [MutationForm.md](./ai-docs/MutationForm.md)
- a form that keeps a target `Lens` continuously synchronized with a valid draft → [LensForm.md](./ai-docs/LensForm.md)
**Small utilities**:
- bridging React-tracked values into an Effect `PubSub` → [PubSub.md](./ai-docs/PubSub.md)
- consuming a raw Effect `Stream` as React state → [Stream.md](./ai-docs/Stream.md)
- resolving a `React.SetStateAction` against a previous value → [SetStateAction.md](./ai-docs/SetStateAction.md)
**Building dev tooling for effect-view itself** (not application code) → [ScopeRegistry.md](./ai-docs/ScopeRegistry.md) (component scope bookkeeping), [Refreshable.md](./ai-docs/Refreshable.md) (hot-reload integration)
## Choosing between async integrations
+24 -5
View File
@@ -1,4 +1,8 @@
# Async
# Async and Memoized
Two `Component` traits that control render behavior: `Async` lets a component's body suspend on an asynchronous Effect; `Memoized` skips re-rendering a component when its props haven't changed. They're commonly combined, since an unmemoized `Async` component restarts its async computation on every unrelated parent render.
## Async
Components run synchronously by default (the body must complete without suspending during render). `Async.async` lifts a component so its body may await an asynchronous Effect before returning JSX; React Suspense handles the wait.
@@ -23,17 +27,32 @@ const User = yield* UserCard.use
.pipe(Async.async, Async.withOptions({ defaultFallback: <p>Loading user...</p> }))
```
## Rules
Rules:
- **Hook ordering**: place every React hook and effect-view hook helper *before* the first operation that may suspend. After a suspend point, the generator continuation runs outside React's synchronous render phase, so no hooks may follow it.
- An async computation restarts on every render of the component unless memoized: pipe `Memoized.memoized` after `Async.async` to skip re-running when props haven't changed (see `Memoized.md`).
- The `promise` prop name is reserved on async components (used internally) — do not declare a prop with that name.
- Async components compare props with `Object.is` by default under `Memoized`, ignoring `fallback`; use `Async.defaultPropsEquivalence` or `Equal.asEquivalence()` for structural comparison when props contain freshly-allocated objects/arrays.
## When to reach for Async vs alternatives
When to reach for `Async` vs alternatives:
- One-off asynchronous read before rendering → `Async.async`.
- Cached/shared/refreshable server reads → `Query` (see `Query.md`).
- User-triggered writes with pending/error state → `Mutation` (see `Mutation.md`).
- Async work in an event handler with no need for `Mutation` state → `useRunPromise`/`useCallbackPromise` (see `Component.md`).
- Subscriptions or background work tied to lifecycle → a scoped fiber forked from `useReactEffect`.
## Memoized
Wraps a component's rendered function with `React.memo`, so an unrelated parent re-render doesn't re-run the component (or restart an `Async` child's in-flight computation) when props are unchanged.
```tsx
import { Async, Component, Memoized } from "effect-view"
export const UserCard = Component.make("UserCard")(function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(() => loadUser(userId), [userId])
return <article>{user.name}</article>
}).pipe(Async.async, Memoized.memoized)
```
- Default comparison is `Object.is` per prop (React.memo default), except on `Async` components where `fallback` is excluded from the comparison by default.
- Override with `Memoized.withOptions({ propsEquivalence })`, e.g. `Equal.asEquivalence()` for full structural equality on immutable data. Supplying `propsEquivalence` replaces the default entirely (so `fallback` is included again for `Async` components unless you exclude it yourself).
- `Memoized` is not exclusive to `Async` — it applies to any `Component` — but it matters most there, since an unmemoized async child otherwise closes/reopens its dependency scope and re-runs its async setup on every parent render.
-16
View File
@@ -1,16 +0,0 @@
# Memoized
Wraps a component's rendered function with `React.memo`, so an unrelated parent re-render doesn't re-run the component (or restart an `Async` child's in-flight computation) when props are unchanged.
```tsx
import { Async, Component, Memoized } from "effect-view"
export const UserCard = Component.make("UserCard")(function* ({ userId }: { readonly userId: string }) {
const user = yield* Component.useOnChange(() => loadUser(userId), [userId])
return <article>{user.name}</article>
}).pipe(Async.async, Memoized.memoized)
```
- Default comparison is `Object.is` per prop (React.memo default), except on `Async` components where `fallback` is excluded from the comparison by default.
- Override with `Memoized.withOptions({ propsEquivalence })`, e.g. `Equal.asEquivalence()` for full structural equality on immutable data. Supplying `propsEquivalence` replaces the default entirely (so `fallback` is included again for `Async` components unless you exclude it yourself).
- Most useful paired with `Async.async`, where an unmemoized child otherwise restarts its async computation and closes/reopens its dependency scope on every parent render.
+10 -6
View File
@@ -1,10 +1,10 @@
# Query
effect-view's take on TanStack Query: reactive query keys, cached results, stale times, background refresh, window-focus refetching, cache invalidation — but the query function is an `Effect` and the observable state is a `View`.
effect-view's take on TanStack Query: reactive query keys, cached results, stale times, background refresh, window-focus refetching, cache invalidation — but the query function is an `Effect` and the observable state is a `View` (see `State.md`).
| TanStack Query | effect-view |
|---|---|
| `QueryClient` | `QueryClient` Effect service (see `QueryClient.md`) |
| `QueryClient` | the `QueryClient` Effect service (below) |
| `queryKey` | a reactive key supplied as a `View<K>` |
| `queryFn` | `f: (key: K) => Effect<A, E, R>` |
| `useQuery` result | `query.state`, a `View<QueryState<K, A, E>>` |
@@ -12,7 +12,9 @@ effect-view's take on TanStack Query: reactive query keys, cached results, stale
| `refetch` | `query.refresh` / `query.refreshView` |
| `invalidateQueries` | `query.invalidateCache` / `invalidateCacheEntry` |
## Provide a QueryClient
## QueryClient: the shared cache
Every `Query` reads and writes through a `QueryClient`, the Effect service that owns the cache and its garbage collection. Add it once to the application runtime:
```tsx
import { Layer } from "effect"
@@ -20,14 +22,16 @@ import { QueryClient, ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer({
defaultStaleTime: "30 seconds",
defaultRefreshOnWindowFocus: true,
cacheGcTime: "5 minutes",
defaultStaleTime: "30 seconds", // default: "0 minutes"
defaultRefreshOnWindowFocus: true, // default: true
cacheGcTime: "5 minutes", // default: "5 minutes"
})),
)
export const runtime = ReactRuntime.make(AppLive)
```
`QueryClient.layer(options?)` builds the service and forks its background garbage-collection loop into the layer's scope. Individual queries may override `staleTime`/`refreshOnWindowFocus`; unset options fall back to these client defaults. `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction. You interact with the client only indirectly through `Query` instances — no need to call `QueryClientService` methods directly.
## Create and run a query
```tsx
@@ -1,22 +0,0 @@
# QueryClient
The Effect service that owns the shared cache used by `Query` (see `Query.md`). Add it once to the application runtime.
```tsx
import { Layer } from "effect"
import { QueryClient, ReactRuntime } from "effect-view"
const AppLive = Layer.empty.pipe(
Layer.provideMerge(QueryClient.layer({
defaultStaleTime: "30 seconds", // default: "0 minutes"
defaultRefreshOnWindowFocus: true, // default: true
cacheGcTime: "5 minutes", // default: "5 minutes"
})),
)
export const runtime = ReactRuntime.make(AppLive)
```
- `QueryClient.layer(options?)` builds the service and forks its background garbage-collection loop into the layer's scope.
- Individual queries may override `staleTime` and `refreshOnWindowFocus`; unset options fall back to these client defaults.
- `cacheGcTime` controls how long a stale, unaccessed cache entry is kept before eviction (checked periodically, not per-request).
- You normally interact with the cache only indirectly, through the `Query` instances built against this client — there is no need to reach into `QueryClientService` methods directly in application code.
@@ -1,8 +1,9 @@
# Lens
# State: Lens and View
The main state primitive. A `Lens` is an effectful handle to a piece of state: read the current value, subscribe to changes, write updates, or focus on a nested field. Every `Lens` is also a `View` (see `View.md`) — the read/subscribe side.
`Lens` and `View` are effect-view's state primitives, re-exported in full from [`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) with two React hooks added on top (`Lens.useState`, `View.useAll`). The core data model, constructors, and focus/derive API belong to `effect-lens` — consult its docs for anything not covered here.
`effect-view` re-exports the full `Lens` module from [`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) and adds one React hook, `Lens.useState`. The core data model, constructors, and focus/derive API belong to `effect-lens` — consult its docs for anything not covered here.
- **`View<A>`** is the read-only half: a current value plus a stream of changes. Use it for anything a component should only observe (derived/computed state, `Query`/`Mutation` state, a read-only field exposed by a service).
- **`Lens<A>`** is a `View<A>` that can also be written to. Every `Lens` is a `View`, so anywhere a `View` is expected, a `Lens` works too.
## Create state
@@ -13,7 +14,7 @@ import { Lens } from "effect-view"
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
```
`Lens.fromSubscriptionRef` is the usual constructor: build state with an Effect primitive (`SubscriptionRef`), then wrap it as a `Lens`.
The usual pattern: build state with an Effect primitive (`SubscriptionRef`), then wrap it as a `Lens` with a matching constructor.
## Where to store it
@@ -24,14 +25,28 @@ const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
| plain `React.useState` | simple local UI state with no need for Effect integration, subscriptions, or sharing |
```tsx
class CounterState extends Context.Service<CounterState, { readonly count: Lens.Lens<number> }>()("CounterState") {
class CounterState extends Context.Service<CounterState, {
readonly count: Lens.Lens<number>
readonly doubled: View.View<number>
}>()("CounterState") {
static readonly layer = Layer.effect(CounterState, Effect.gen(function* () {
const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0))
return { count } as const
const doubled = View.map(count, n => n * 2) // derived, read-only
return { count, doubled } as const
}))
}
```
## Read: View.useAll
```tsx
const [count, doubled] = yield* View.useAll([state.count, state.doubled])
```
- Reads current values during render, then subscribes via a scoped stream and updates React state on change.
- This is the default way to read a `Lens` too, since `Lens` is a `View`.
- `View.useAll(views, { equivalence? })``equivalence` controls when a combined change across the supplied views counts as meaningful (defaults to comparing element-wise with `Equal.strictEqual()`).
## Write
```tsx
@@ -41,14 +56,14 @@ yield* Lens.set(state.count, 0)
## Lens.useState — read/write tuple for controlled inputs
Use when a JSX API wants React's `[value, setValue]` shape (controlled `<input>`, checkbox, select, third-party `value`/`onChange` props). If a component only needs to display the value, prefer `View.useAll` instead.
Use when a JSX API wants React's `[value, setValue]` shape (controlled `<input>`, checkbox, select, third-party `value`/`onChange` props). If a component only needs to *display* the value, prefer `View.useAll` — reach for `Lens.useState` only where reading and writing need to be wired together in React's local-state shape.
```tsx
const [name, setName] = yield* Lens.useState(state.name)
<input value={name} onChange={e => setName(e.currentTarget.value)} />
```
Calling the setter writes through the `Lens`, so every other subscriber (including `View.useAll` elsewhere, or another `Lens.useState` on the same lens) sees the update. `Lens.useState(lens, { equivalence })` controls when a change triggers a re-render.
Calling the setter writes through the `Lens`, so every other subscriber (another `Lens.useState`, or a `View.useAll` elsewhere) sees the update. `Lens.useState(lens, { equivalence? })` controls when a change triggers a re-render. The setter accepts a plain value or a `prev => next` updater, resolved internally by `SetStateAction.value` (see `SetStateAction.md`).
## Focused Lenses
+1 -1
View File
@@ -9,4 +9,4 @@ const latest = yield* Stream.use(someStream, initialValue) // Effect<Some<A>, ne
`Stream.use(stream, initialValue?)` subscribes to `stream` for the component's lifetime (via a scoped fiber forked in a post-commit effect) and returns the latest emitted value as React state, deduped with strict equality. Without `initialValue` the result starts as `Option.none()` until the first emission; with one, it starts as `Option.some(initialValue)`.
Prefer `View.useAll` (see `View.md`) when the source is already a `View`/`Lens` — reach for `Stream.use` when you have a raw Effect `Stream` to observe directly.
Prefer `View.useAll` (see `State.md`) when the source is already a `View`/`Lens` — reach for `Stream.use` when you have a raw Effect `Stream` to observe directly.
-21
View File
@@ -1,21 +0,0 @@
# View
The read-only side of the state model: a current value plus a stream of changes. Every `Lens` (see `Lens.md`) is a `View`; things like `Query`/`Mutation` state and derived/mapped state are also exposed as `View`.
`effect-view` re-exports the full `View` module from [`effect-lens`](https://www.npmjs.com/package/effect-lens/v/beta) and adds one React hook, `View.useAll`.
## View.useAll — bind Views into render output
```tsx
const [count, doubled] = yield* View.useAll([state.count, state.doubled])
```
- Reads the current values during render, then subscribes via a scoped stream and updates React state when any of them changes.
- This is the default way to read a `Lens` from a component, since `Lens` is a `View`.
- Pass `{ equivalence }` to control when a combined change across all supplied views is considered meaningful (defaults to comparing the tuple element-wise with `Equal.strictEqual()`).
```tsx
const doubled = View.map(count, n => n * 2) // derive a read-only View
```
Use plain `View` values (rather than `Lens`) for anything a component should only observe, never write — e.g. derived/computed state exposed by a service.