From 9e24b131b1233c2f8670bcc3726c61455dd4e6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julien=20Valverd=C3=A9?= Date: Mon, 24 Aug 2026 02:56:44 +0200 Subject: [PATCH] Add AI docs --- packages/effect-view/AGENTS.md | 52 ++++++++ packages/effect-view/ai-docs/Async.md | 39 ++++++ packages/effect-view/ai-docs/Component.md | 103 ++++++++++++++++ packages/effect-view/ai-docs/Form.md | 59 +++++++++ packages/effect-view/ai-docs/Lens.md | 72 +++++++++++ packages/effect-view/ai-docs/LensForm.md | 25 ++++ packages/effect-view/ai-docs/Memoized.md | 16 +++ packages/effect-view/ai-docs/Mutation.md | 89 ++++++++++++++ packages/effect-view/ai-docs/MutationForm.md | 50 ++++++++ packages/effect-view/ai-docs/PubSub.md | 9 ++ packages/effect-view/ai-docs/Query.md | 114 ++++++++++++++++++ packages/effect-view/ai-docs/QueryClient.md | 22 ++++ packages/effect-view/ai-docs/ReactRuntime.md | 39 ++++++ packages/effect-view/ai-docs/Refreshable.md | 7 ++ packages/effect-view/ai-docs/ScopeRegistry.md | 7 ++ .../effect-view/ai-docs/SetStateAction.md | 9 ++ packages/effect-view/ai-docs/Stream.md | 12 ++ packages/effect-view/ai-docs/View.md | 21 ++++ packages/effect-view/package.json | 4 +- 19 files changed, 748 insertions(+), 1 deletion(-) create mode 100644 packages/effect-view/AGENTS.md create mode 100644 packages/effect-view/ai-docs/Async.md create mode 100644 packages/effect-view/ai-docs/Component.md create mode 100644 packages/effect-view/ai-docs/Form.md create mode 100644 packages/effect-view/ai-docs/Lens.md create mode 100644 packages/effect-view/ai-docs/LensForm.md create mode 100644 packages/effect-view/ai-docs/Memoized.md create mode 100644 packages/effect-view/ai-docs/Mutation.md create mode 100644 packages/effect-view/ai-docs/MutationForm.md create mode 100644 packages/effect-view/ai-docs/PubSub.md create mode 100644 packages/effect-view/ai-docs/Query.md create mode 100644 packages/effect-view/ai-docs/QueryClient.md create mode 100644 packages/effect-view/ai-docs/ReactRuntime.md create mode 100644 packages/effect-view/ai-docs/Refreshable.md create mode 100644 packages/effect-view/ai-docs/ScopeRegistry.md create mode 100644 packages/effect-view/ai-docs/SetStateAction.md create mode 100644 packages/effect-view/ai-docs/Stream.md create mode 100644 packages/effect-view/ai-docs/View.md diff --git a/packages/effect-view/AGENTS.md b/packages/effect-view/AGENTS.md new file mode 100644 index 0000000..acb9d7e --- /dev/null +++ b/packages/effect-view/AGENTS.md @@ -0,0 +1,52 @@ +# effect-view + +`effect-view` lets a React function component be described as an Effect program: yield services, create scoped resources, subscribe to reactive state, and turn Effects into React callbacks inside a component body, then convert that description into a normal React function component at a React boundary. + +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. + +## Core model + +1. `ReactRuntime` builds the Effect services available to the UI and exposes them through React context. +2. `Component.make` defines a component body as an Effect generator. +3. `Component.withContext` converts a `Component` into a normal React component at a React boundary (app root, router, third-party library). Apply it only there — never between two effect-view components. +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 + +| 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 | + +## Choosing between async integrations + +- One-off async read before rendering → `Async.async`. +- Cached/shared/refreshable server reads → `Query`. +- User-triggered writes with observable pending/error state → `Mutation`. +- Async work in an event handler with no need for `Mutation` state → `Component.useRunPromise`/`useCallbackPromise`. +- Subscriptions or background work tied to component lifecycle → a scoped fiber forked from `Component.useReactEffect`. + +## Common pitfalls + +- Never yield an asynchronous Effect directly from a regular (non-`Async`) component body. +- effect-view hooks are still React hooks under the hood: call them unconditionally, at the top level, in a stable order — never in branches, loops, or after a suspend point. +- Keep `ReactRuntime` instances and `Layer` references stable; building them during render creates new resources and a new React context every time. +- `Component.withContext` requires a matching `ReactRuntime.Provider` above it in the tree. diff --git a/packages/effect-view/ai-docs/Async.md b/packages/effect-view/ai-docs/Async.md new file mode 100644 index 0000000..f8392a8 --- /dev/null +++ b/packages/effect-view/ai-docs/Async.md @@ -0,0 +1,39 @@ +# 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. + +```tsx +import { Effect } from "effect" +import { Async, Component } 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) +``` + +Render with a fallback (per-use or as a component default): + +```tsx +const User = yield* UserCard.use +Loading user...

} /> +``` + +```tsx +.pipe(Async.async, Async.withOptions({ defaultFallback:

Loading user...

})) +``` + +## 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 + +- 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`. diff --git a/packages/effect-view/ai-docs/Component.md b/packages/effect-view/ai-docs/Component.md new file mode 100644 index 0000000..c783460 --- /dev/null +++ b/packages/effect-view/ai-docs/Component.md @@ -0,0 +1,103 @@ +# Component + +Defines a React function component as an Effect program. A `Component` is a description, not yet a React component — cross into React with `Component.withContext` or `.use`. + +## Define + +```tsx +import { Effect } from "effect" +import { Component } from "effect-view" + +export const HelloView = Component.make("HelloView")(function* (props: { readonly name: string }) { + const message = yield* Effect.succeed(`Hello, ${props.name}`) + return

{message}

+}) +``` + +- `Component.make(spanName?)(generatorBody, ...pipeArgs)` — same overloads as `Effect.fn`/`Effect.gen`: a generator body, or a body plus `(_, props) => next` pipeline steps. Passing a `spanName` wraps the body in a tracing span and sets `displayName`. +- `Component.makeUntraced` is identical but skips the automatic span (still sets `displayName` from the name argument). +- The component's props type, return type, error channel, and required services (`R`) are all inferred from the generator body. + +## Cross into React + +```tsx +export const Hello = HelloView.pipe(Component.withContext(runtime.context)) +// +``` + +`Component.withContext(context)` reads the Effect context supplied by the matching `ReactRuntime.Provider` and turns the component into a plain `React.FC`. Apply it only at boundaries where plain React (a router, a third-party lib, an app root) needs a function component — never between two effect-view components. + +## Compose inside effect-view + +```tsx +const Hello = yield* HelloView.use +return +``` + +`component.use` is an `Effect>` that binds the child to the current Effect context/scope and returns a stable function-component reference. Yield it from a parent component body; do not call `withContext` here. + +## Lifecycle hooks + +Hooks are plain React hooks under the hood: call them unconditionally, at the top level, in the same order every render — never in branches, loops, callbacks, or after a suspend point. + +Every rendered component instance gets a root `Scope.Scope`, created on mount and closed on unmount, provided to the whole body. `Effect.addFinalizer`, `Effect.acquireRelease`, `Effect.forkScoped` used directly in the body run against this scope. + +| Hook | Purpose | Scope | Closes | +|---|---|---|---| +| body | produce rendered output | component root scope | unmount | +| `useOnMount(() => effect)` | compute + cache once | component root scope | unmount | +| `useOnChange(() => effect, deps)` | recompute on deps change | new scope per dep set | deps change / unmount | +| `useReactEffect(() => effect, deps?)` | post-commit side effect (`Effect.useEffect` analog) | new scope | deps change / unmount | +| `useReactLayoutEffect(() => effect, deps?)` | pre-paint side effect | new scope | deps change / unmount | +| `useLayer(layer, options?)` | build + provide a `Layer`, returns its `Context` | new scope tied to layer identity | layer ref changes / unmount | + +```tsx +const state = yield* Component.useOnMount(() => + Effect.gen(function* () { + yield* Effect.addFinalizer(() => Effect.log("disposed")) + return Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0)) + }), +) +``` + +- Setup passed to `useOnMount`/`useOnChange`/`useLayer` must complete **synchronously** in a regular component (it runs during render). Wrap the component with `Async.async` to allow suspending setup. +- `useReactEffect`/`useReactLayoutEffect` setup must also start synchronously but may `Effect.forkScoped` async work into the hook's own scope. +- `useRunSync`/`useRunPromise`/`useCallbackSync`/`useCallbackPromise` do not create a new scope; they capture the component root scope (and any extra services you request) by default. + +## Run Effects from event handlers + +```tsx +const runPromise = yield* Component.useRunPromise() // or useRunPromise() + +``` + +- `useRunSync()` — only for Effects guaranteed to complete synchronously. +- `useRunPromise()` — for Effects that may suspend/sleep/fetch. +- `useCallbackSync(f, deps)` / `useCallbackPromise(f, deps)` — memoized variants (same deps semantics as `React.useCallback`) for passing stable callbacks to children. +- Both runners provide `Scope.Scope` automatically; add extra services with an explicit type argument (`useRunPromise()`). + +## Provide services + +Static layer, one instance per mounted component, disposed on unmount: + +```tsx +const GreetingViewLive = GreetingView.pipe(Component.provide(GreetingService.layer)) +``` + +Layer built from render-time state (props/context), provided to children explicitly: + +```tsx +const layer = React.useMemo(() => Layer.succeed(GreetingService, {...}), [props.greeting]) +const context = yield* Component.useLayer(layer) +const Greeting = yield* Effect.provide(GreetingView.use, context) +return +``` + +Keep layer references stable (module scope or `React.useMemo`) — a new layer object triggers rebuild and finalizer cleanup. Async layer construction requires `Async.async` on the owning component. + +## Common pitfalls + +- Never yield an asynchronous Effect from a regular component body — use `Async.async`, `Query`, a `Mutation` callback, or a scoped fiber forked from a post-commit hook instead. +- `Component.withContext` needs a matching `ReactRuntime.Provider` above it in the tree. +- Prefer `useRunPromise` over `useRunSync` for event handlers that may be async. +- Regular React hooks, refs, context, and state work normally inside a component body alongside `yield*`. diff --git a/packages/effect-view/ai-docs/Form.md b/packages/effect-view/ai-docs/Form.md new file mode 100644 index 0000000..f52a06c --- /dev/null +++ b/packages/effect-view/ai-docs/Form.md @@ -0,0 +1,59 @@ +# Form + +The shared model implemented by both root form types (`MutationForm.md`, `LensForm.md`) and every subform focused from them. A schema is the single source of truth for shape, validation, and the decoded value the application receives; `Form` supplies reactive state and lifecycle on top of it — it does not render anything. + +A schema distinguishes the **encoded value** the UI edits from the **decoded value** the application uses: + +```tsx +import { Schema } from "effect" + +const ProfileSchema = Schema.Struct({ + displayName: Schema.String.check(Schema.isMinLength(1, { message: "Enter a display name" })), + age: Schema.NumberFromString, // input edits a string, app gets a number + contact: Schema.Struct({ + email: Schema.String.check(Schema.isPattern(/^[^\s@]+@[^\s@]+\.[^\s@]+$/, { message: "Enter a valid email" })), + }), +}) +``` + +## The Form interface + +| Member | Meaning | +|---|---| +| `encodedValue` | writable input-shaped state (a `Lens`) | +| `value` | decoded value as `Option`; `None` until decoding succeeds | +| `issues` | Standard Schema issues scoped to this form's path | +| `isValidating` | whether schema decoding is currently running | +| `canCommit` | whether the root has a valid value and is ready to commit | +| `isCommitting` | whether a mutation or target write is in progress | + +There is no separate "field" type — a field is just a `Form` focused on part of its parent. + +## Focus into subforms + +```tsx +const displayNameField = Form.focusObjectOn(form, "displayName") +const contactForm = Form.focusObjectOn(form, "contact") +const emailField = form.pipe(Form.focusObjectOn("contact"), Form.focusObjectOn("email")) +``` + +- `Form.focusObjectOn` (struct key), `Form.focusArrayAt` (array index), `Form.focusTupleAt` (tuple index), `Form.focusChunkAt` (Chunk index). All are dual API: data-first, or curried for chaining through nested paths with `pipe`. +- A focused form exposes `encodedValue`/`value`/`issues` scoped to that path; `isValidating`/`canCommit`/`isCommitting` stay connected to the root form. +- Focus once (e.g. at component setup), not on every render. + +## Bind a subform to an input + +```tsx +const input = yield* Form.useInput(emailField, { debounce: "250 millis" }) + input.setValue(e.currentTarget.value)} /> +``` + +- `Form.useInput(form, { debounce? })` returns `{ value, setValue }` from the subform's encoded value. `setValue` writes the form and re-runs the schema pipeline. `debounce` delays propagation to the form (the displayed value updates immediately) — useful for text inputs to avoid validating every keystroke. +- `Form.useOptionalInput(form, options?)` is for an encoded `Option` field: returns `{ value, setValue, enabled, setEnabled }` for a togglable optional input. +- `Form.useStatus(form)` returns `{ isValidating, isCommitting, canCommit }`, debounced to avoid flicker in pending indicators. + +These hooks are building blocks for your own reusable input components — wrap them once to handle labels, issues, disabled state, and styling consistently; both hooks accept any `Form.Form`, so the same input component works with subforms from `MutationForm` or `LensForm`. + +## Schema-owned conversions + +Because the schema defines both directions, it can own an entire domain conversion — e.g. a local `datetime-local` input string decoding to a UTC `DateTime.Utc` and back — with no manual parsing in components. See `MutationForm.md` for a complete example (`DateTimeUtcFromZonedInput`). diff --git a/packages/effect-view/ai-docs/Lens.md b/packages/effect-view/ai-docs/Lens.md new file mode 100644 index 0000000..a5f210e --- /dev/null +++ b/packages/effect-view/ai-docs/Lens.md @@ -0,0 +1,72 @@ +# Lens + +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. + +`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. + +## Create state + +```tsx +import { SubscriptionRef } from "effect" +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`. + +## Where to store it + +| Owner | When | +|---|---| +| Effect service (`Context.Service` + `Layer`) | shared by multiple components / application-level state | +| `Component.useOnMount` | owned by one component instance (or a shallow subtree receiving it via props) | +| plain `React.useState` | simple local UI state with no need for Effect integration, subscriptions, or sharing | + +```tsx +class CounterState extends Context.Service }>()("CounterState") { + static readonly layer = Layer.effect(CounterState, Effect.gen(function* () { + const count = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(0)) + return { count } as const + })) +} +``` + +## Write + +```tsx +yield* Lens.update(state.count, n => n + 1) +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. + +```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. + +## Focused Lenses + +A focused Lens is still a `Lens` — read it with `View.useAll` or `Lens.useState` like any other. + +```tsx +const nameLens = Lens.focusObjectOn(state.profile, "name") +const cityLens = state.profile.pipe( + Lens.focusObjectOn("contact"), + Lens.focusObjectOn("address"), + Lens.focusObjectOn("city"), +) +``` + +- Focus helpers (`focusObjectOn`, `focusArrayAt`, `focusTupleAt`, `focusChunkAt`, ...) are dual API: data-first (`Lens.focusObjectOn(lens, key)`) or curried for `pipe` chaining through nested paths. +- Create focused lenses once (e.g. in `Component.useOnMount`), not on every render. Writes through a focused lens propagate to the parent lens. +- Full focus/derive/custom-write API: see the `effect-lens` docs. + +## Bridging plain React state into a Lens + +`Lens.useFromReactState([value, setValue])` wraps an existing React state tuple as a `Lens`, keeping both directions in sync — useful when integrating a third-party hook that already owns `[value, setValue]` state. diff --git a/packages/effect-view/ai-docs/LensForm.md b/packages/effect-view/ai-docs/LensForm.md new file mode 100644 index 0000000..0b28c7d --- /dev/null +++ b/packages/effect-view/ai-docs/LensForm.md @@ -0,0 +1,25 @@ +# LensForm + +A root form (implements `Form.Form`, see `Form.md`) that keeps an encoded draft synchronized in both directions with a target `Lens` of decoded application data. Use for settings panels, inspectors, and edit screens where a valid change should update existing state without a final submit. + +```tsx +import { Effect, SubscriptionRef } from "effect" +import { Component, Lens, LensForm, View } from "effect-view" + +const [form, profile] = yield* Component.useOnMount(() => + Effect.gen(function* () { + const profile = Lens.fromSubscriptionRef( + yield* SubscriptionRef.make({ displayName: "Ada", age: 37, contact: { email: "ada@example.com" } }), + ) + const form = yield* LensForm.make({ schema: ProfileSchema, target: profile }).pipe(LensForm.thenRun) + return [form, profile] as const + }), +) + +const [savedProfile, isCommitting] = yield* View.useAll([profile, form.isCommitting]) +``` + +- `LensForm.make({ schema, target, initialEncodedValue? })` — `target` holds decoded data. `LensForm.thenRun` starts synchronization. +- A valid edit is decoded and written to `target` automatically; an invalid edit stays in the form (so the user can correct it) and never reaches `target`. If something else updates `target`, `LensForm` encodes that value back into the draft. +- Pass `initialEncodedValue` only when the first draft should differ from the encoded target; otherwise `LensForm.make` derives the initial draft by encoding the current target through the schema. +- No `submit` method — commits happen continuously as valid edits arrive. Focus into subforms/fields with `Form.focusObjectOn`/etc. and bind with `Form.useInput` exactly as with `MutationForm` (see `Form.md`). diff --git a/packages/effect-view/ai-docs/Memoized.md b/packages/effect-view/ai-docs/Memoized.md new file mode 100644 index 0000000..bfb1125 --- /dev/null +++ b/packages/effect-view/ai-docs/Memoized.md @@ -0,0 +1,16 @@ +# 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/Mutation.md b/packages/effect-view/ai-docs/Mutation.md new file mode 100644 index 0000000..13ffbb1 --- /dev/null +++ b/packages/effect-view/ai-docs/Mutation.md @@ -0,0 +1,89 @@ +# Mutation + +effect-view's counterpart to TanStack Query mutations: user-triggered asynchronous work (save, delete, upload, send) as an Effect. No cache, no reactive key, no automatic execution — it runs only when called. + +| TanStack Mutation | effect-view | +|---|---| +| mutation variables | the input key `K` | +| `mutationFn` | `f: (key: K) => Effect` | +| mutation result | `mutation.state`, a `View>` | +| `isPending` | `result.waiting` | +| `mutateAsync` | `mutation.mutate(key)` | +| start without awaiting | `mutation.mutateView(key)` | + +## Create + +```tsx +import { Mutation } from "effect-view" + +const mutation = yield* Component.useOnMount(() => + Mutation.make({ f: (input: InviteInput) => sendInvite(input) }), +) +``` + +`Mutation.make({ f })` is an Effect constructor, not a hook — create each instance once (`Component.useOnMount` for component-owned, an Effect service for shared) and keep it stable. `f` keeps its full `Effect` type; required services are captured from the creation context, so callbacks don't reconstruct dependencies. Fibers belong to the creation scope and are interrupted if that scope closes while running. + +## AsyncResult state + +`mutation.state` starts `Initial` (`waiting: false`); calling `mutate`/`mutateView` sets `waiting: true`, then publishes `Success` or `Failure`. + +```tsx +import { AsyncResult } from "effect/unstable/reactivity" + +const [result] = yield* View.useAll([mutation.state]) + +AsyncResult.match(result, { + onInitial: ({ waiting }) => (...), + onFailure: ({ cause, previousSuccess, waiting }) => (...), // cause: Cause + onSuccess: ({ value, waiting }) => (...), +}) +``` + +`waiting` is independent of the result tag: after one success, starting another call keeps the value visible while `waiting: true`; if that call fails, the failure can retain `previousSuccess`. Failures carry a full `Cause`. + +## mutate vs mutateView + +| Method | Returns | Use for | +|---|---|---| +| `mutate(key)` | the final `Success`/`Failure` | an Effect workflow that needs the outcome | +| `mutateView(key)` | a live per-call `View>` | a UI callback that just starts the work | + +```tsx +const runPromise = yield* Component.useRunPromise() +void runPromise(Effect.gen(function* () { + const result = yield* mutation.mutate(input) + if (AsyncResult.isSuccess(result)) yield* Effect.log(`Saved ${result.value.id}`) +})) +``` + +```tsx +const runSync = yield* Component.useRunSync() +const state = runSync(mutation.mutateView(input)) // a View for this specific call +``` + +The mutation Effect never fails with `E` itself — it captures the operation's `Exit` and always resolves to a final `AsyncResult.Success`/`Failure`. + +## Reactive metadata + +| Member | Meaning | +|---|---| +| `state` | latest mutation state, shared `View` | +| `latestKey` | most recent input, `Option` | +| `latestFinalResult` | latest completed success/failure, `Option` | +| `fiber` | most recently started mutation fiber, `Option` | + +## Concurrency + +Starting a mutation does not interrupt an earlier one — calls can overlap, each with its own `mutateView` state; `mutation.state` reflects whichever update arrived last. For a single submit button, disabling while `result.waiting` is usually enough. Use per-call `mutateView` Views (e.g. per uploaded file) when concurrent operations each need their own progress indicator. + +## Updating queries after a mutation + +Mutations never auto-invalidate `Query` caches — compose it explicitly: + +```tsx +const result = yield* updatePost.mutate(input) +if (AsyncResult.isSuccess(result)) { + yield* posts.invalidateCacheEntry(["post", result.value.id] as const) + yield* posts.refreshView // invalidation alone does not refetch +} +``` diff --git a/packages/effect-view/ai-docs/MutationForm.md b/packages/effect-view/ai-docs/MutationForm.md new file mode 100644 index 0000000..d082a3e --- /dev/null +++ b/packages/effect-view/ai-docs/MutationForm.md @@ -0,0 +1,50 @@ +# MutationForm + +A root form (implements `Form.Form`, see `Form.md`) that owns a local encoded draft and passes the valid decoded value to a `Mutation` when `submit` runs. Use for registration, checkout, search — any workflow with an explicit submit action. + +```tsx +import { Effect } from "effect" +import { Component, MutationForm, View } from "effect-view" + +const form = yield* Component.useOnMount(() => + MutationForm.make({ + schema: ProfileSchema, + initialEncodedValue: { displayName: "", age: "", contact: { email: "" } }, + f: ([profile]) => Effect.log(`Creating ${profile.displayName}, age ${profile.age}`), + }).pipe(MutationForm.thenRun), +) + +const [canCommit, isCommitting] = yield* View.useAll([form.canCommit, form.isCommitting]) +const runPromise = yield* Component.useRunPromise() + + +``` + +- `MutationForm.make({ schema, initialEncodedValue, f })` constructs the form; `f` receives the decoded value(s) as its mutation input (`profile.age` is a number even though the input edited a string). `MutationForm.thenRun` starts initial validation in the current scope. +- Create once and keep stable — the usual home is `Component.useOnMount`. +- `form.submit` runs the mutation only when the form can currently commit; schema issues block submission before the mutation ever runs. +- Focus into subforms/fields with `Form.focusObjectOn`/`focusArrayAt`/etc. (see `Form.md`) and bind them to inputs with `Form.useInput`. + +## Example: schema-owned date conversion + +```tsx +class DateTimeUtcFromZoned extends Schema.transformOrFail(Schema.DateTimeZonedFromSelf, Schema.DateTimeUtcFromSelf, { + strict: true, + decode: input => ParseResult.succeed(DateTime.toUtc(input)), + encode: DateTime.setZoneCurrent, +}) {} + +export class DateTimeUtcFromZonedInput extends Schema.transformOrFail(Schema.String, DateTimeUtcFromZoned, { + strict: true, + decode: (input, _options, ast) => Effect.flatMap(DateTime.CurrentTimeZone, timeZone => + Option.match(DateTime.makeZoned(input, { timeZone, adjustForTimeZone: true }), { + onSome: ParseResult.succeed, + onNone: () => ParseResult.fail(new ParseResult.Type(ast, input, "Enter a valid date and time")), + })), + encode: value => ParseResult.succeed(DateTime.formatIsoZoned(value).slice(0, 16)), +}) {} +``` + +An `` edits `"2026-07-22T14:30"`; the schema decodes it to a UTC `DateTime.Utc` (`decode(...).pipe requires DateTime.CurrentTimeZone` — provide `DateTime.layerCurrentZoneLocal` in the runtime) and the mutation receives the UTC instant directly. No manual date parsing in the component. diff --git a/packages/effect-view/ai-docs/PubSub.md b/packages/effect-view/ai-docs/PubSub.md new file mode 100644 index 0000000..91ffbed --- /dev/null +++ b/packages/effect-view/ai-docs/PubSub.md @@ -0,0 +1,9 @@ +# PubSub + +Re-exports Effect's `PubSub` module in full (`export * from "effect/PubSub"`) and adds one component hook. + +```tsx +const pubsub = yield* Component.useOnMount(() => Effect.acquireRelease(PubSub.unbounded
(), PubSub.shutdown)) +``` + +`PubSub.useFromReactiveValues(values: DependencyList)` creates a scoped, unbounded `PubSub` on mount and publishes `values` to it every time the dependency array changes (skipping publish once the PubSub has been shut down). Useful for bridging a set of React-tracked reactive values into an Effect `Stream`-based consumer inside the component's scope. diff --git a/packages/effect-view/ai-docs/Query.md b/packages/effect-view/ai-docs/Query.md new file mode 100644 index 0000000..b333a2d --- /dev/null +++ b/packages/effect-view/ai-docs/Query.md @@ -0,0 +1,114 @@ +# 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`. + +| TanStack Query | effect-view | +|---|---| +| `QueryClient` | `QueryClient` Effect service (see `QueryClient.md`) | +| `queryKey` | a reactive key supplied as a `View` | +| `queryFn` | `f: (key: K) => Effect` | +| `useQuery` result | `query.state`, a `View>` | +| `isFetching` | `result.waiting` | +| `refetch` | `query.refresh` / `query.refreshView` | +| `invalidateQueries` | `query.invalidateCache` / `invalidateCacheEntry` | + +## Provide a QueryClient + +```tsx +import { Layer } from "effect" +import { QueryClient, ReactRuntime } from "effect-view" + +const AppLive = Layer.empty.pipe( + Layer.provideMerge(QueryClient.layer({ + defaultStaleTime: "30 seconds", + defaultRefreshOnWindowFocus: true, + cacheGcTime: "5 minutes", + })), +) +export const runtime = ReactRuntime.make(AppLive) +``` + +## Create and run a query + +```tsx +import { Effect, Schema, SubscriptionRef } from "effect" +import { HttpClient } from "effect/unstable/http" +import { Component, Lens, Query, View } from "effect-view" + +const [postId, query] = yield* Component.useOnMount(() => + Effect.gen(function* () { + const key = Lens.fromSubscriptionRef(yield* SubscriptionRef.make(["post", 1 as number] as const)) + + const query = yield* Query.make({ + key, + staleTime: "1 minute", + f: ([, id]) => + HttpClient.HttpClient.pipe( + Effect.andThen(client => client.get(`https://example.com/posts/${id}`)), + Effect.andThen(res => res.json), + Effect.andThen(Schema.decodeUnknownEffect(Post)), + ), + }).pipe(Query.thenRun) + + return [Lens.focusTupleAt(key, 1), query] as const + }), +) +``` + +- `Query.make({ key, f, staleTime?, refreshOnWindowFocus?, keyEquivalence? })` constructs the query; `Query.thenRun` starts watching `key` in the current scope. +- Create each query once and keep it stable — the usual home is `Component.useOnMount` (component-owned) or an Effect service (shared across components). Change the key, don't recreate the query, when input changes. +- Keys use Effect equality by default (`keyEquivalence` overrides it). A tuple key plays the same role as `['post', id]` in TanStack Query. +- `f` keeps its full `Effect` type: required services, schema decoding, retries, tracing all compose normally. The context is captured at creation time. +- Changing the key interrupts the previous in-flight request; a fresh cached success or a new run for the new key follows. + +## Render AsyncResult + +`query.state: View>` where `QueryState = { key: K; result: AsyncResult }`. + +```tsx +import { AsyncResult } from "effect/unstable/reactivity" + +const [state] = yield* View.useAll([query.state]) + +AsyncResult.match(state.result, { + onInitial: ({ waiting }) => waiting ?

Loading...

:

Not loaded.

, + onFailure: ({ cause, previousSuccess, waiting }) => (/* cause: Cause, previousSuccess: Option */), + onSuccess: ({ value, waiting }) => (/* value: A, waiting: refreshing in background */), +}) +``` + +`waiting` is independent of the result tag: a background refresh keeps a `Success` successful (with its value) while `waiting: true`; a failed refresh can retain `previousSuccess`. Failures carry a full `Cause` (typed errors, defects, interruption), not just `E`. + +## Refresh, fetch, invalidate + +| Method | Behavior | +|---|---| +| `fetch(key)` | fetch a specific key, wait for its final state | +| `fetchView(key)` | start fetching a key, return immediately as a live state `View` | +| `refresh` | resolve the current key again, wait for its final state | +| `refreshView` | resolve the current key again, return immediately as a live `View` | +| `invalidateCacheEntry(key)` | remove the cached success for one key | +| `invalidateCache` | remove every cached success for this query | + +The `*View` variants suit synchronous UI callbacks (`runSync(query.refreshView)`); the non-`View` variants suit Effect workflows waiting on the outcome. **Invalidating does not refetch by itself** — follow with `refreshView`, a key change, or a later natural fetch. + +```ts +import { Schedule } from "effect" + +const query = yield* Query.make(options).pipe( + Query.thenRun, + Query.withScheduledRefresh(Schedule.spaced("5 minutes").pipe(Schedule.upTo({ times: 3 }))), +) +``` + +`Query.withScheduledRefresh(schedule)` forks a refresh fiber tied to the surrounding scope; cache/`staleTime` rules still apply. + +## Staleness and lifetime + +- `staleTime` (per query, falls back to `QueryClient` default): how long a successful result satisfies a fetch without re-running `f`. A stale entry stays available as previous data while it refreshes. +- `cacheGcTime` (on `QueryClient`): entries unused for `staleTime + cacheGcTime` are evicted. +- `refreshOnWindowFocus` (per query, falls back to `QueryClient` default): re-resolves the current key on window focus. Requires the optional `@effect/platform-browser` package; without it, the option is silently ignored (rest of the API works normally). Also a no-op outside browser environments. + +## The Effect touch + +Request fibers belong to the creation scope and are interrupted on unmount, key replacement, or scope closure. Results are `View`s usable outside React too. Mutations do not auto-invalidate queries — compose it explicitly (see `Mutation.md`). diff --git a/packages/effect-view/ai-docs/QueryClient.md b/packages/effect-view/ai-docs/QueryClient.md new file mode 100644 index 0000000..d9656df --- /dev/null +++ b/packages/effect-view/ai-docs/QueryClient.md @@ -0,0 +1,22 @@ +# 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/ReactRuntime.md b/packages/effect-view/ai-docs/ReactRuntime.md new file mode 100644 index 0000000..c2a6de1 --- /dev/null +++ b/packages/effect-view/ai-docs/ReactRuntime.md @@ -0,0 +1,39 @@ +# ReactRuntime + +Owns a managed Effect runtime and exposes it to a React subtree through context. Every effect-view application has exactly one root runtime per independent Effect context tree. + +## Create and provide + +```tsx +import { Layer } from "effect" +import { ReactRuntime } from "effect-view" + +const AppLive = Layer.empty // add application layers here + +export const runtime = ReactRuntime.make(AppLive) +``` + +```tsx +import { StrictMode } from "react" +import { createRoot } from "react-dom/client" +import { ReactRuntime } from "effect-view" +import { runtime } from "./runtime" +import { App } from "./App" + +createRoot(document.getElementById("root")!).render( + + Starting...

}> + +
+
, +) +``` + +- `ReactRuntime.make(layer, memoMap?)` builds a `ManagedRuntime` and a `React.Context` in one value. Define it at module scope — never inside a render. +- `ReactRuntime.Provider` builds the runtime layer (which can suspend, hence `fallback`), makes the resulting context available via React context, and disposes the managed runtime on unmount. +- With a router, keep the provider above the router provider. Put a React error boundary above it if runtime construction can fail. + +## Rules + +- One runtime instance per app (or per independent subtree that needs its own root context). +- `ReactRuntime.Provider` only supplies context; it never builds automatically at other boundaries — `Component.withContext` reads it explicitly. diff --git a/packages/effect-view/ai-docs/Refreshable.md b/packages/effect-view/ai-docs/Refreshable.md new file mode 100644 index 0000000..5e99cf0 --- /dev/null +++ b/packages/effect-view/ai-docs/Refreshable.md @@ -0,0 +1,7 @@ +# Refreshable + +**Low-level module for development-server integrations** (e.g. `@effect-view/vite-plugin`'s hot-reload support). Not used directly in application code. + +`Refreshable.attach(component, cell)` connects a `Component` descriptor to a mutable "refresh cell" (`Refreshable.makeCell(component, signature, forceReset)`) that a dev-server integration updates as source files change. The attached component renders through a stable wrapper that swaps in the cell's current implementation on each update, using `React.useSyncExternalStore` to trigger a re-render and, when `signature` changes or `forceReset` is set, remounting the component (via a changed `key`) to discard React state that can no longer be trusted to match the new code. + +If you are writing a bundler/dev-server integration for effect-view, this is the API to build on. Otherwise, ignore this module. diff --git a/packages/effect-view/ai-docs/ScopeRegistry.md b/packages/effect-view/ai-docs/ScopeRegistry.md new file mode 100644 index 0000000..775e015 --- /dev/null +++ b/packages/effect-view/ai-docs/ScopeRegistry.md @@ -0,0 +1,7 @@ +# ScopeRegistry + +**Internal module — not part of the application-facing API.** Do not use this directly in application code; it exists to support `Component`'s lifecycle hooks (`Component.useScope` and everything built on it). + +`ScopeRegistry` is an Effect service (auto-provided by `ReactRuntime.make` via `ReactRuntime.preludeLayer`) that tracks the `Scope.Scope` associated with each mounted component instance: `register` creates one, `commit` marks it as "React has committed this render" (cancelling its abandonment timeout), and `release` schedules it for closure after `finalizerExecutionDebounce` once the owning component/dependency-set unmounts. A background loop closes scopes whose debounce has elapsed and force-closes any scope left uncommitted past `scopeCommitTimeout` (guards against an abandoned render, e.g. one thrown away by React Strict Mode or a Suspense retry). + +Relevant only if you're building low-level tooling around effect-view's rendering internals. diff --git a/packages/effect-view/ai-docs/SetStateAction.md b/packages/effect-view/ai-docs/SetStateAction.md new file mode 100644 index 0000000..f041bd5 --- /dev/null +++ b/packages/effect-view/ai-docs/SetStateAction.md @@ -0,0 +1,9 @@ +# SetStateAction + +A single helper for resolving React's `SetStateAction` (`S | ((prev: S) => S)`) against a previous value — the same logic `React.useState`'s setter applies internally. + +```ts +SetStateAction.value(setStateAction, prevState) // dual API, also curried: SetStateAction.value(prevState)(setStateAction) +``` + +Used internally by `Lens.useState` to support functional updates (`setValue(prev => prev + 1)`); rarely needed directly in application code unless you're building a custom hook that accepts a `React.SetStateAction`. diff --git a/packages/effect-view/ai-docs/Stream.md b/packages/effect-view/ai-docs/Stream.md new file mode 100644 index 0000000..fe6f6d3 --- /dev/null +++ b/packages/effect-view/ai-docs/Stream.md @@ -0,0 +1,12 @@ +# Stream + +Re-exports Effect's `Stream` module in full (`export * from "effect/Stream"`) and adds one component hook for consuming a stream as React state. + +```tsx +const latest = yield* Stream.use(someStream) // Effect, never, R> +const latest = yield* Stream.use(someStream, initialValue) // Effect, never, R> +``` + +`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. diff --git a/packages/effect-view/ai-docs/View.md b/packages/effect-view/ai-docs/View.md new file mode 100644 index 0000000..ebf5b6a --- /dev/null +++ b/packages/effect-view/ai-docs/View.md @@ -0,0 +1,21 @@ +# 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. diff --git a/packages/effect-view/package.json b/packages/effect-view/package.json index f005083..ab48410 100644 --- a/packages/effect-view/package.json +++ b/packages/effect-view/package.json @@ -5,10 +5,12 @@ "type": "module", "files": [ "./README.md", + "./AGENTS.md", "./src/**/*.ts", "./dist/**/*.js", "./dist/**/*.js.map", - "./dist/**/*.d.ts" + "./dist/**/*.d.ts", + "./ai-docs/**/*.md" ], "license": "MIT", "repository": {