@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user