---
title: Canonical patterns
description: One rule per heading — where state lives, how a REC is placed, query vs suspend, typed errors, loading, mutations, streams, and the client/server boundary — each with a short compiling example.
group: Advanced
order: 1
---

A dense, canonical reference: one rule per heading, an imperative statement, a short example. Everything
imports from `@tmonier/effract`; Effect helpers come from `effect/*`. Assume these imports throughout —
later examples omit them.

```tsx
import {
  rec, hook, mount, atom, derive, atomFamily, batch, defer,
  query, suspend, scoped, mutation, QueryCache,
} from '@tmonier/effract';
import { useState } from 'react';
import * as Context from 'effect/Context';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';
import * as Option from 'effect/Option';
```

## Put state and logic in an Effect service, not `useState`

Reactive state that drives rendering — a `userId`, a filter, a cart — lives in a service `atom`, reachable
from any component, fiber, or the server. Use `useState` only for genuinely transient, component-local UI
(a dropdown's open flag). A `userId` that drives a query is service state, not `useState`.

```tsx
class Session extends Context.Service<Session>()('app/Session', {
  make: Effect.sync(() => ({ userId: atom('userId', 'u1') })), // label first — every cell is named
}) {
  static readonly layer = Layer.effect(Session, Session.make);
}

const Profile = rec(function* () {
  const session = yield* Session;
  const id = yield* session.userId; // service atom, not useState
  const user = yield* query(fetchUser(id), id); // the atom drives the query
  return <Card user={user} />;
});
```

## Place a REC with `{yield* Child}`, never `<Child/>`

A REC is a descriptor, not a JSX element — `<Child/>` is a compile error. Place it by yielding it inside
another REC's returned JSX; pass props with `.with(...)`.

```tsx
const Page = rec(function* () {
  return (
    <main>
      {yield* Dashboard}
      {yield* Greet.with({ name: 'Ada' })}
    </main>
  );
});
// <Dashboard /> ✗ — a REC is not a JSX element.
```

## Write the body as a plain generator, not `Effect.gen`

`rec` takes a `function* () {}`. It is driven inside React's render pass, not run as an Effect. Do not wrap
it in `Effect.gen`.

```tsx
const Ok = rec(function* () {
  const s = yield* Stats;
  return <b>{s.online}</b>;
}); // ✓ plain generator
// rec(Effect.gen(function* () { ... }))  ✗
```

## Bridge a React hook with `hook(...)` — client modules only

Lift any real React hook through `hook(...)`. Importing a hook makes the module a client island; hooks are
not exported from the server build.

```tsx
const Search = rec(function* () {
  const [open, setOpen] = yield* hook(useState(false)); // transient UI only
  return <button onClick={() => setOpen(!open)}>{open ? 'close' : 'open'}</button>;
});
```

## Use one `mount` for client and server

`mount(layer, Root)` is the same import everywhere; the `react-server` export condition picks the impl.
`mount` verifies at compile time that `layer` provides every service the tree reads.

```tsx
createRoot(el).render(mount(AppLive, Root)); // client
export default mount(AppLive, Root); // server (RSC) — identical call
```

## Stream SSR with `renderToStream`; hydrate with the plain `mount`

The server-only `@tmonier/effract/ssr` subpath exports `renderToStream(layer, Root, options?)` — 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 `mount` seeds it automatically. No
mismatch, no double fetch, no hydration API. Mark wire-crossing state with
`atom.serializable(key, initial, schema)` — the one opt-in; the key is the label.

```tsx
// server
const stream = await renderToStream(AppLive, Root, { shell: (app) => <Doc>{app}</Doc> });
// client — nothing extra
hydrateRoot(el, mount(AppLive, Root));
```

## On the server, keep per-request state inside a service's `make`

The server `mount` builds a fresh runtime per request by default (`scope: 'isolated'`), so atoms created in
a service's `make` cannot leak across requests. A module-level `atom` is process-global — a footgun. Use
`{ scope: 'shared' }` only for a stateless, global layer.

```tsx
export default mount(AppLive, Page); // isolated (default): fresh runtime per request
export default mount(AppLive, Page, { scope: 'shared' }); // one runtime per layer, process-wide
const wrong = atom('hits', 0); // ✗ module scope — shared by every request on the server
```

## Pick the atom primitive by need

| Need | Primitive |
| --- | --- |
| writable cell | `atom(label, initial)` — label first; `.value` / `.set(v)` / `.update(fn)` / `.subscribe(fn)` |
| computed from one atom | `atom.derive((v) => …)` (chains; inherits the source's label + volatility) |
| computed from several atoms | `derive([a, b], ([x, y]) => …)` (explicit sources — labels/volatility inherited) |
| two-way computed (write flows back) | `derive.writable([a], ([x]) => …, (v) => …)` |
| async computed (suspends, carries `S`) | `derive.effect([a], ([x]) => effect)` |
| one atom per key | `atomFamily(label, make, { keyOf?, gc? })` (`gc: 'unreferenced' \| { maxSize }`) |
| coalesce synchronous writes | `batch(() => { … })` (sync only — no `await`) |
| drain an Effect `Stream` into an atom | `atom.fromStream(label, stream, initial)` (in a service `make`; born `Volatile`) |

```tsx
const total = items.derive((xs) => xs.reduce((n, i) => n + i.price, 0)); // one source — the method form
const subtotal = derive([price, qty], ([p, q]) => p * q); // several sources — explicit tuple
batch(() => {
  first.set('Ada');
  last.set('Lovelace');
}); // one notification, not two
```

## Pick between `query`, `suspend`, `scoped`, and raw async

| Need | Use | Cache | Refetch | Loading obligation `S` |
| --- | --- | --- | --- | --- |
| keyed async data, shared cache | `query(effect, key, opts?)` | yes (by key, per mount) | on key change / tag / refetch | yes |
| load once, no refetch | `suspend(effect)` | deduped per instance | no | yes |
| component-lifetime resource (finalizer on unmount) | `scoped(effect)` | acquired once per instance | no | yes |
| suspend but own your boundary | `yield* asyncEffect` | no | no | no (`S = never`) |

```tsx
const user = yield* query(fetchUser(id), id); // keyed refetch + cache + obligation
const config = yield* suspend(loadConfig()); // load once + obligation
const socket = yield* scoped(Effect.acquireRelease(open(url), (s) => Effect.sync(() => s.close()))); // released exactly once, on unmount; Scope provided — it never reaches mount
const thing = yield* Effect.promise(() => fetchThing()); // suspends, no obligation — bring your own <Suspense>
```

## Handle typed errors exhaustively with `.catch`

`.catch({ Tag: (e) => node })` is exhaustive over the REC's error channel `E`. A forgotten tag or an
invented tag is a compile error; each handler receives exactly its own error. `.catch` returns a plain REC.

```tsx
const Profile = rec(function* () {
  const user = yield* query(fetchUser(id), id); // E = NotFound | Unauthorized
  return <Card user={user} />;
}).catch({
  NotFound: () => <Empty />,
  Unauthorized: (e) => <Login reason={e.reason} />, // e fully typed
});
```

## Opt into a last resort for defects with `.catchDefect`

Defects and untagged failures always propagate to the nearest error boundary — unless a REC explicitly
opts into `.catchDefect(handler)`. Tags win first; the handler gets the original thrown value plus
`retry`; in dev every absorbed defect is reported on the devtools seam. Returns a REC with `E = never`.

```tsx
const Widget = rec(function* () {
  const data = yield* query(load, key);
  return <Chart data={data} />;
})
  .catch({ NotFound: () => <Empty /> }) // typed failures first
  .catchDefect((_defect, { retry }) => <ErrorCard onRetry={retry} />); // everything else
```

## Discharge the loading obligation with `.suspense` or `{ loading }`

A `query`/`suspend` records a loading obligation `S` in the type; `mount` will not compile until it is
discharged. `.suspense(fallback)` handles it in the tree; `{ loading }` handles it at the boundary. Errors
and loading are separate channels — chain `.catch(...).suspense(...)`.

```tsx
mount(AppLive, Profile); // ✗ LoadingNotHandled
mount(AppLive, Profile.suspense(<Spinner />)); // ✓ discharged in the tree
mount(AppLive, Profile, { loading: <Spinner /> }); // ✓ discharged at the boundary
```

## Declare mutations in services with `mutation` — optimistic apply, typed rollback

`mutation(label, run, { optimistic?, onSuccess?, invalidates? })` is yielded in a service `make`, so the
policy lives with the data. `optimistic` applies a change and returns a rollback thunk called
automatically on a **typed** failure (the failure lands, typed, in `state.error`; defects never do);
`invalidates` reloads tagged queries on success. The handle is `{ run, state, reset }` — `state` is a
labeled atom (`'users/rename.state'`) a REC reads with `yield*`.

```tsx
class Users extends Context.Service<Users>()('app/Users', {
  make: Effect.gen(function* () {
    const user = atom('user', { name: 'Ada' });
    const rename = yield* mutation('users/rename', (name: string) => api.rename(name), { // Effect<User, Forbidden>
      optimistic: (name) => { const prev = user.value; user.set({ ...prev, name }); return () => user.set(prev); },
      invalidates: ['user'], // reload tagged queries on success
    });
    return { user, rename };
  }),
}) {
  static readonly layer = Layer.effect(Users, Users.make);
}

const state = yield* users.rename.state; // in a REC — reactive read, re-renders per transition
users.rename.run('Ada'); // status → 'pending'; rolls back if run fails
```

## Drive the query cache from a service — `yield* QueryCache`

Tag a query for invalidation and stale-while-revalidate. The cache is the built-in `QueryCache` service
(provided by every client `mount` and `renderToStream`): `cache.invalidate(tag)` reloads every tagged
query (active queries background-revalidate, inactive drop); `cache.refetch(key)` re-runs one keyed
query. Both return how many queries matched. `query.enabled` gates a dependent query and reads
`Option<A>`.

```tsx
const feed = yield* query(loadFeed, 'feed', { tags: ['feed'], staleTime: 30_000 }); // in a REC

class Feed extends Context.Service<Feed>()('app/Feed', {
  make: Effect.gen(function* () {
    const cache = yield* QueryCache; // the built-in service
    return {
      reload: () => cache.invalidate('feed'), // reload every 'feed'-tagged query
      retry: () => cache.refetch('feed'), // re-run one keyed query — no remount
    };
  }),
}) {
  static readonly layer = Layer.effect(Feed, Feed.make);
}

const profile = yield* query.enabled(fetchProfile(uid), uid, uid !== undefined);
if (Option.isSome(profile)) return <Card user={profile.value} />; // none while disabled
```

## Bridge a stream with `atom.fromStream` in a service; read the atom in the REC

`atom.fromStream(label, stream, initial)` is an Effect that drains a `Stream` into an atom on a
scope-bound fiber. Call it in a service `make`; components read the atom with `yield*` and re-render per
emission. The atom is born `Volatile` — see the next rule.

```tsx
class Ticker extends Context.Service<Ticker>()('app/Ticker', {
  make: Effect.gen(function* () {
    const price = yield* atom.fromStream('price', priceStream, 0); // Atom<number, Volatile, 'price'>
    return { price };
  }),
}) {
  static readonly layer = Layer.effect(Ticker, Ticker.make);
}
```

## Acknowledge a volatile subscription with `defer(atom)` or `.hot()`

A stream atom re-renders its readers at the source's own frequency, so it carries a volatility
obligation `V` that `mount` refuses to compile undischarged
(`effract: volatile subscription not handled`). Discharge at the read with `defer(atom)` — the value
arrives through `useDeferredValue`, bursts coalesce — or at the component with `.hot()`, the deliberate
acknowledgment that this subtree is *meant* to be live. Prefer `defer` when the UI can lag a frame;
keep `.hot()` leaves small. See [render control](/docs/render-control/).

```tsx
const Lagged = rec(function* () {
  const ticker = yield* Ticker;
  const price = yield* defer(ticker.price); // ✓ V discharged at the read
  return <b>{price}</b>;
});

const Live = rec(function* () {
  const ticker = yield* Ticker;
  const price = yield* ticker.price; // V = Volatile
  return <b>{price}</b>;
}).hot(); // ✓ V discharged at the component — deliberately source-frequency
```

## Let the boundary enforce itself — hooks aren't exported from the server build

A hook-bearing REC cannot be a Server Component: `hook`, `observe`, `useAtom` are not exported from the
`react-server` build, so reaching for one in an RSC module is a compile error. `atom`/`derive` are
exported there (React-free state a service can hold); only reading an atom in a component needs a hook.

```tsx
// page.tsx (a React Server Component)
import { hook } from '@tmonier/effract'; // ✗ compile error: "hook" is not exported
// Fix: move the REC to a 'use client' module → it becomes a client island again.
```

## Common compile errors → fix

| Error | Cause | Fix |
| --- | --- | --- |
| REC not assignable to JSX (`<Child/>`) | placed a REC as a JSX element | `{yield* Child}` / `{yield* Child.with(props)}` |
| `MissingServices<…>` at `mount` | the layer omits a service the tree reads | merge the service's layer into the mount layer |
| `LoadingNotHandled` at `mount` | a `query`/`suspend` obligation `S` is undischarged | `.suspense(fallback)` or `mount(…, { loading })` |
| `VolatileNotHandled` at `mount` | a stream atom's obligation `V` is undischarged | read it with `defer(atom)`, or mark the REC `.hot()` |
| missing property in `.catch` (or excess) | a tag in `E` has no handler (or an invented tag) | add `Tag: (e) => node`; drop tags `E` can't raise |
| `"hook" is not exported` | used a hook in a server/RSC module | move the REC to a `'use client'` module |
