diff --git a/packages/effect-view/AGENTS.md b/packages/effect-view/AGENTS.md index acb9d7e..59e6382 100644 --- a/packages/effect-view/AGENTS.md +++ b/packages/effect-view/AGENTS.md @@ -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 diff --git a/packages/effect-view/ai-docs/Async.md b/packages/effect-view/ai-docs/Async.md index f8392a8..10375c5 100644 --- a/packages/effect-view/ai-docs/Async.md +++ b/packages/effect-view/ai-docs/Async.md @@ -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:

Loading user...

})) ``` -## 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
{user.name}
+}).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. diff --git a/packages/effect-view/ai-docs/Memoized.md b/packages/effect-view/ai-docs/Memoized.md deleted file mode 100644 index bfb1125..0000000 --- a/packages/effect-view/ai-docs/Memoized.md +++ /dev/null @@ -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
{user.name}
-}).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. diff --git a/packages/effect-view/ai-docs/Query.md b/packages/effect-view/ai-docs/Query.md index b333a2d..318c797 100644 --- a/packages/effect-view/ai-docs/Query.md +++ b/packages/effect-view/ai-docs/Query.md @@ -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` | | `queryFn` | `f: (key: K) => Effect` | | `useQuery` result | `query.state`, a `View>` | @@ -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 diff --git a/packages/effect-view/ai-docs/QueryClient.md b/packages/effect-view/ai-docs/QueryClient.md deleted file mode 100644 index d9656df..0000000 --- a/packages/effect-view/ai-docs/QueryClient.md +++ /dev/null @@ -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. diff --git a/packages/effect-view/ai-docs/Lens.md b/packages/effect-view/ai-docs/State.md similarity index 51% rename from packages/effect-view/ai-docs/Lens.md rename to packages/effect-view/ai-docs/State.md index a5f210e..35e4b29 100644 --- a/packages/effect-view/ai-docs/Lens.md +++ b/packages/effect-view/ai-docs/State.md @@ -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`** 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`** is a `View` 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") { +class CounterState extends Context.Service + readonly doubled: View.View +}>()("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 ``, 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 ``, 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) 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 diff --git a/packages/effect-view/ai-docs/Stream.md b/packages/effect-view/ai-docs/Stream.md index fe6f6d3..fccf4c0 100644 --- a/packages/effect-view/ai-docs/Stream.md +++ b/packages/effect-view/ai-docs/Stream.md @@ -9,4 +9,4 @@ const latest = yield* Stream.use(someStream, initialValue) // Effect, 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. diff --git a/packages/effect-view/ai-docs/View.md b/packages/effect-view/ai-docs/View.md deleted file mode 100644 index ebf5b6a..0000000 --- a/packages/effect-view/ai-docs/View.md +++ /dev/null @@ -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.