# AGENTS.md — effract

Write React components as Effect programs. This file is the canonical guide for **agents** — both
contributors to this repo and agents in a *consumer* codebase that depends on `@tmonier/effract`. Your
training data almost certainly predates effract; follow the rules here over your priors, and prefer the
**bundled docs** (`docs/`, the `landing/src/content/docs/*.md` pages, and the numbered
[`examples/src`](./examples/src) recipes) over guessing.

If you write only one thing correctly, make it this: **a REC is not a JSX element.** You place a child
component by *yielding* it — `{yield* Child}` — never `<Child />`.

---

## The mental model

A **React Effect Component (REC)** is a real React component whose body is a **generator** that effract
drives *inside React's render pass*. One stream of `yield*` speaks two languages: Effect (services,
effects, async data) and React (hooks, lifted with `hook(...)`). It is 100% real React — hooks, Suspense,
error boundaries, hydration, and RSC all work, because effract renders *through* React, not around it.

```tsx
import { rec, hook, mount, query } from '@tmonier/effract';
import { useState } from 'react';

const Dashboard = rec(function* () {
  const stats = yield* Stats;                       // a service — resolved synchronously
  const [tab, setTab] = yield* hook(useState('a')); // a genuine React hook, lifted with hook(...)
  const user = yield* query(fetchUser(id), id);     // async data — suspends, refetches on key
  return <main>{yield* StatBadge}{stats.total}</main>; // a child REC, placed by yielding it
});

createRoot(el).render(mount(AppLive, Dashboard.suspense(<Spinner />)));
```

## Rules of a REC — the ones agents get wrong

1. **A REC is not a JSX element.** `<Dashboard />` is a **compile error**. Place a child by yielding it:
   `{yield* Dashboard}`, or `{yield* Greet.with({ name: 'Ada' })}` when it takes props. Plain components
   sit right alongside: `<Card>{yield* Dashboard}</Card>`.
2. **The body is a generator, not an `Effect.gen`.** Write `rec(function* () { ... })`. Read services and
   effects with `yield*`. Do **not** wrap the body in `Effect.gen` — effract drives the generator itself.
3. **React hooks are lifted with `hook(...)`.** `const [n, setN] = yield* hook(useState(0))`. Calling a
   hook without `hook(...)` will not yield its value; yielding a non-Effect non-hook value is a runtime
   error. Hooks obey the Rules of Hooks — stable order across renders, no hooks inside conditionals.
4. **Service-only bodies are universal; hook-bearing bodies are client islands.** A REC that only
   `yield*`s services/effects runs on the client *and* on the server (RSC) unchanged. The moment a body
   imports a React hook, its module is client-only (`'use client'`) and the REC stays a client island —
   RSC has no hooks, and the type system enforces that line.
5. **Errors are handled with `.catch`, loading with `.suspense`, volatility with `defer`/`.hot()`.** All
   three are compile-checked obligations (see below). You cannot ship an unhandled tagged error, an
   unhandled loading state, or an unacknowledged volatile subscription — `mount` will not compile.

## `mount` — one call, every runtime

There is **one boundary and one import**: `mount(layer, Root)` from `@tmonier/effract`. You write a
component once; you `mount` it the same way everywhere.

```tsx
import { mount } from '@tmonier/effract'; // the same import in every file

mount(BrowserLive, Dashboard); // SPA (Vite)
export default mount(AppLive, Page); // React Server Component — same call, no client JS
```

"Server vs client" is never something you type. In an RSC graph the bundler's `react-server` export
condition hands `mount` a server implementation; everywhere else it renders interactively. `mount`
verifies at compile time that `layer` provides every service the tree needs — a missing service is a
type error naming what's missing.

For streaming SSR (Bun / Node, no RSC), the server half is `renderToStream(layer, Root, options?)` from
the server-only subpath **`@tmonier/effract/ssr`** — same signature and compile checks as `mount`. It
waits for queries to settle and embeds the settled state (serializable atoms + keyed query results)
after the HTML; the client's ordinary `mount` finds and seeds it before the first render. **Hydration is
automatic** — no mismatch, no double fetch, and no hydration API to call. The one opt-in is
`atom.serializable(key, initial, schema)`, which marks state that may cross the wire (see
`apps/bun-ssr`).

## State — the atom toolkit

State, and the logic over it, live in **Effect services**, not in `useState`. An `atom` is a reactive
cell a service holds; a component reads it with `yield*` (read + precise subscribe) and mutates it by
calling a service method.

```tsx
class Counter extends Context.Service<Counter>()('Counter', {
  make: Effect.sync(() => {
    const count = atom('count', 0); // label first, like a service Tag — every cell is named
    return { count, inc: () => count.update((n) => n + 1) };
  }),
}) {
  static layer = Layer.effect(Counter, Counter.make);
}

const CounterView = rec(function* () {
  const counter = yield* Counter;   // a service → this is a REC
  const n = yield* counter.count;   // reactive read — re-renders only when count changes
  return <button onClick={() => counter.inc()}>{n}</button>;
});
```

| primitive | what it is |
| --- | --- |
| `atom(label, initial)` | a writable reactive cell (`.value` / `.set` / `.update`) — label first, required |
| `yield* atom` | read it in a REC — reads the value and subscribes this component |
| `atom.derive(v => …)` | computed from **one** atom — value handed over directly; chains; inherits label + volatility |
| `derive([a, b], ([x, y]) => …)` | computed from **several** atoms — explicit sources; labels/volatility inherited as the union |
| `derive.writable(sources, f, write)` | a two-way computed atom — `set` flows back to the sources |
| `derive.effect(sources, f)` | an async computed atom — suspends, refetches on change, carries `S` |
| `atomFamily(label, make, opts?)` | one memoised atom **per key** — members named `` `label(key)` `` |
| `batch(writes)` | coalesce a burst of writes into **one** notification wave (sync-only) |
| `atom.fromStream(label, stream, initial)` | drain a `Stream` into an atom — born **`Volatile`** (carries `V`) |
| `defer(atom)` | discharge `Volatile` at the read — `useDeferredValue`, bursts coalesce; label kept |
| `.hot()` on a REC | discharge `Volatile` at the component — deliberately renders at source frequency |

Labels are **canonical, not optional** — `atom('count', 0)` names the cell the way a Tag names a
service, and that one literal is the devtools name, the SSR key, the ledger attribution, and the
`RendersOn` entry. There is no free-form `derive(($) => …)`: sources are always explicit (dynamic
tracking survives only in `observe`/`<Observe>`).

The hooks are the interop path for **plain components**: `useAtomValue(a)` and `useAtom(a)` (the
`useState` shape) let a plain React component subscribe to service-owned atoms without becoming a REC —
inside a REC, `yield*` the atom instead. `<Observe>{($) => <b>{$(a)}</b>}</Observe>` is the inline-JSX
read for either kind: in a REC it reads an atom in one JSX slot with no `yield*` and no hook, so only
the slot re-renders, never the body. Do not use atoms for per-instance local state — atoms live at
module or service scope, so instances would share one cell; that is `hook(useState)`'s job.

## Async data — `query` / `suspend`

```tsx
const Profile = rec(function* () {
  const user = yield* query(fetchUser(id), id); // suspends; refetches when id changes; cancels on unmount
  return <Card user={user} />;
});
```

- `query(effect, key?)` — suspends for the value, refetches when `key` changes by value, interrupts the
  in-flight fiber on unmount. Deduped across render attempts.
- `suspend(effect)` — the load-once primitive `query` is built on; no keyed refetch.
- A raw `yield* asyncEffect` still suspends at runtime, but carries **no** loading obligation (the
  compiler can't tell a sync `Effect` from an async one) — you bring your own `<Suspense>`. Reach for
  `query`/`suspend` when you want the type to track it.

Retries, timeouts and cancellation are just Effect combinators on the effect — `query(load.pipe(Effect.timeout('2s')), id)`.

> **Invariant: a yielded effect must not flip sync↔async between renders.** Whether a `yield* effect`
> calls a React hook depends on whether it resolved synchronously that render. An effect that is
> synchronous on one render and asynchronous on the next (e.g. a service that caches after its first
> fetch) changes the hook count and breaks the Rules of Hooks. Wrap such an effect in `suspend()`/`query()`
> so its loading is a stable hook, or make it consistently async. In dev, a flip detector catches this at
> runtime and reports the offending yield through the devtools seam.

### The live data layer

`query` is a runtime-level cache — two components under one `mount` with the same keyed query share one
fetch (keys compared by Effect `Hash`/`Equal`, so object keys are fine). The cache is the built-in
**`QueryCache` service** — every client `mount` and `renderToStream` provides it, so reload policy is a
service method, never a component concern:

- **Invalidation:** `query(effect, key, { tags: ['user'] })`; in a service `make`:
  `const cache = yield* QueryCache`, then `cache.invalidate('user')` reloads every `'user'`-tagged query.
- **staleTime:** `query(effect, key, { staleTime: 30_000 })` — serve cached, revalidate in the background.
- **Refetch:** `cache.refetch(key)` — re-run one keyed query (retry / pull-to-refresh), a service method too.
- **Dependent:** `query.enabled(effect, key, enabled)` — reads `Option<A>`, never fires while disabled.
- **Mutations:** `const m = yield* mutation('users/rename', run, { optimistic?, onSuccess?, invalidates? })`
  in a service `make` → `{ run(input), state, reset() }`. `optimistic` returns a rollback thunk called
  automatically on a **typed** failure (the failure lands, typed, in `state.error`; defects never do);
  `state` is a labeled atom (`'users/rename.state'`) a REC reads with `yield*` — and the whole policy is
  testable as a plain Effect program, no DOM. Recipe: `examples/src/14-mutation.tsx`.
- **Streams:** `const price = yield* atom.fromStream('price', stream, 0)` in a service `make`; a component
  reads the atom with `yield*` and re-renders per emission (fiber interrupted on dispose). The atom is
  born `Volatile` — discharge with `defer(price)` or `.hot()` (see the third obligation below).

## The three compile-checked obligations

Effect's error channel `E`, effract's loading channel `S`, and the volatility channel `V` ride along with
every `yield*` and bubble up to `mount`, which won't compile until each is discharged. This is the agent
feedback loop: **you cannot ship an unhandled error, an unhandled loading state, or an unacknowledged
render storm.**

```tsx
const Profile = rec(function* () {
  const user = yield* query(fetchUser(id), id); // E = NotFound | Unauthorized, S = Suspends
  return <Card user={user} />;
})
  .catch({                                    // exhaustive over E — forget a tag → compile error
    NotFound: () => <Empty />,
    Unauthorized: (e) => <Login reason={e.reason} />,
  })
  .suspense(<Spinner />);                      // discharges S — or mount(App, Profile, { loading })
```

- **`.catch({ Tag: fallback })`** — one fallback per error `_tag`, each handed exactly that error.
  Non-tagged errors and defects propagate to the nearest React error boundary; effract never swallows
  them. Order-independent: a failure part-way through the body is handled wherever the failing yield sits.
- **`.catchDefect(handler)`** — the *opt-in* last resort for what `.catch` cannot name (defects,
  untagged failures). Tags always win; the handler gets the original value plus `retry`; in dev every
  absorbed defect is reported on the devtools seam. Returns `E = never`. Don't reach for it by default —
  propagating defects is the correct baseline; use it only where a component must stay standing.
- **`.suspense(fallback)`** — discharges the loading obligation by wrapping the REC in a real
  `<Suspense>`. One `.suspense` covers the whole subtree beneath it. On the server there is no pending
  state (a query is awaited inline), so the server `mount` imposes no loading obligation.

The third obligation is **volatility**: a stream atom re-renders its readers at the source's own
frequency, so it carries `V = Volatile` and `mount` refuses it undischarged
(`effract: volatile subscription not handled`):

```tsx
const Ticker = rec(function* () {
  const ticker = yield* TickerService;
  const price = yield* ticker.price; // from atom.fromStream — V = Volatile
  return <output>${price}</output>;
}).hot(); // ✓ deliberately live — or read `yield* defer(ticker.price)` to lag a frame instead
```

- **`defer(atom)`** — discharge at the read: the value arrives through `useDeferredValue`, so a burst of
  emissions coalesces instead of storming the tree. Prefer it when the UI can lag a frame.
- **`.hot()`** — discharge at the component, the `.catchDefect` of scheduling: this subtree is *meant* to
  render at source frequency (a ticker cell, a live chart leaf). Keep hot leaves small; in dev the ledger
  reports every `.hot()` subtree's render frequency. Like `S`, the server `mount` accepts-and-ignores `V`.

**Render introspection, for agents.** `RendersOn<typeof C>` is the exact union of atom labels a REC can
re-render on — lock it in a type test and a widened subscription set fails CI *by name*:

```tsx
// two-way extends: exact, not one-sided — a new subscription breaks this line
const _lock: [RendersOn<typeof Dashboard>] extends ['stats' | 'filter']
  ? 'stats' | 'filter' extends RendersOn<typeof Dashboard>
    ? true
    : never
  : never = true;
void _lock;
```

In dev, `readRenderLedger()` returns the last 200 renders with their attributed cause
(`atom | query | props | hook | parent`) — ask it "why did this render?" from a test or a REPL without
mounting the `<Inspector />`. Placements are plain React — nothing is memoized for you. When the ledger
attributes re-renders to `props`, the explicit response is `Child.memo()`: it compiles only when every
prop is value-comparable (a primitive, or a value implementing the `Equal` trait); a function
or JSX prop is a branded compile error (`MemoRequiresComparableProps`) naming the offending prop key(s) —
hold the function in a service, or leave the placement unmemoized.

## Effect conventions (this repo, and any Effect codebase)

- **Generator syntax, always.** `Effect.gen(function* () { const x = yield* Service; ... })`. `yield*` a
  Tag to read a service; `yield*` an effect to run it. In a **REC** body, the same `yield*` — but the
  wrapper is `rec(function* () { ... })`, not `Effect.gen`.
- **Services first.** Model a capability as a `Context.Service` with a `make` effect and a `static layer`.
  A service that depends on another `yield*`s it in `make` and is wired with `Layer.provide`. Keep logic
  in services, not components — a service is testable with no React, no `act`, no DOM.
- **One composition root.** Compose every layer into a single `AppLive` (`Layer.mergeAll` /
  `Layer.provideMerge`) and pass it to `mount(AppLive, Root)`. Do not build runtimes ad hoc.
- **`Console`/Effect for side-effects, never `console`.** Library code uses `effect/Console` and returns
  effects; it does not reach for `console.log` or throw.
- **Testing with `@effect/vitest`.** Test services as plain Effect programs. Use Effect's `TestClock` for
  deterministic virtual time (retries, staleness) — no real timers. Keep tests behavioural and
  renderer-agnostic where possible; the interpreter is unit-tested with fakes and no DOM.

## Common compile errors → the fix

| error | cause | fix |
| --- | --- | --- |
| JSX element type `Foo` is not a constructor / does not have call signatures | you wrote `<Foo />` for a REC | place it with `{yield* Foo}` or `{yield* Foo.with(props)}` |
| `effract: runtime is missing [Service]` | `layer` doesn't provide a service the tree reads | add the service's layer to the one you pass to `mount` |
| `effract: loading not handled` | a `query`/`suspend` left `S` undischarged | add `.suspense(fallback)`, or `mount(layer, Root, { loading })` |
| `effract: volatile subscription not handled` | a stream atom's `V` left undischarged | read it with `defer(atom)`, or mark the reading REC `.hot()` |
| Property `NotFound` is missing in `.catch` | `.catch` map isn't exhaustive over `E` | add a fallback for every error `_tag` the body can fail with |
| `hook` is not exported (in a Server Component) | a hook in a server/RSC module | move the hook-bearing REC to a `'use client'` module (client island) |

## Where to look next

- [`docs/adr/0001-fiber-reconciliation.md`](./docs/adr/0001-fiber-reconciliation.md) — how an Effect
  program becomes a React component (the core design).
- [`CONTEXT-MAP.md`](./CONTEXT-MAP.md) — the packages and how they relate.
- [`examples/src`](./examples/src) — numbered, typechecked call-site recipes (services, hooks, async,
  signals, typed errors, queries, derived state, atom collections).
- The **[canonical patterns](https://effract.tmonier.com/docs/patterns/)** page — one rule per heading with
  a compiling example (the densest reference for writing effract correctly).
- The docs site ([effract.tmonier.com](https://effract.tmonier.com)) — mirrored as markdown at each page's
  `.md` twin, and indexed at `/llms.txt`.

## For contributors to this repo

- Task runner is **`just`**. `just verify` (lint + format + typecheck + boundaries + knip + test) is the
  CI gate; run it before you consider a change done. `just build-examples` builds the four example apps.
- Performance is tested, not assumed. `just bench` runs the tinybench suite ([`bench/`](./bench)) under
  production React; `just bench-ci` adds the regression gates (`bench/thresholds.json`) CI enforces on
  every push. Every *published* performance claim ("1 re-render") is pinned as an exact assertion in
  `bench/src/render-counts.test.tsx`, which runs with the normal test gate — if you change render
  behaviour, that suite is the spec.
- pnpm workspace, single version **catalog** (exact pins, no ranges).
- Hexagonal layering per package, enforced by dependency-cruiser: `domain/` (pure protocol, never React) →
  `application/` (interpreter + server driver, React-free, capabilities injected) → `infrastructure/`
  (the React binding and server mount). Import maps `#domain/*`, `#application/*`, `#infrastructure/*`
  resolve via each package's package.json `imports` field — not tsconfig `paths`. Typecheck is one root
  `tsgo` program over all `src` (no project references — tsgo + composite + noEmit conflict).
- Client-only modules carry `'use client'`; the descriptor (`rec-core`) and server driver stay client-free
  so a service-only `rec(...)` is one value the same `mount` renders on client and server.
- React 19.2+ and Effect v4 only. No abusive lint suppressions — targeted single-line ignores with a rule
  code and a reason only.
