---
title: Render control
description: The render channel — every atom is named, a volatile subscription is a compile error until acknowledged with defer or .hot(), RendersOn locks a component's subscription set in CI, and the dev ledger attributes every render to its cause.
group: Core
order: 7
---

React can't tell you why a component rendered. effract makes render behavior a **channel**, like errors
(`E`) and loading (`S`): every reactive cell is named, a subscription that would render at source
frequency is a compile-checked obligation (`V`), and in development every render is attributed to its
cause. Nothing here is opt-in — escape hatches exist, opt-ins do not.

## Name every atom — the label is canonical

`atom(label, initial)`, label first, exactly like `Context.Service<X>()('X', …)` names a service. The
label is captured as a **literal type** and is the cell's one name everywhere: the type-level
[`RendersOn`](#lock-a-components-render-surface-with-renderson) entry, the devtools trace, the ledger
attribution, and (for serializable atoms) the wire key — `atom.serializable(key, initial, schema)` puts
its key first because the key *is* the label.

```tsx
const count = atom('count', 0); // Atom<number, never, 'count'>
```

Derived cells are never manually named — they **inherit**. `a.derive(f)` carries its source's label (and
volatility); the explicit-sources forms carry the union over their tuple:

```tsx
const subtotal = derive([price, qty], ([p, q]) => p * q); // ReadableAtom<number, never, 'price' | 'qty'>
```

## Treat volatility as an obligation, not a hint

A stream atom emits at its source's own frequency — so `atom.fromStream(label, stream, initial)` is born
**`Volatile`**. The obligation `V` bubbles through placements exactly like the loading channel `S`, and
`mount` (and `renderToStream`) refuse to compile until it is discharged:

```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);
}

const Price = rec(function* () {
  const ticker = yield* Ticker;
  const price = yield* ticker.price; // V = Volatile
  return <b>{price}</b>;
});

mount(Ticker.layer, Price);
// ✗ effract: volatile subscription not handled — defer(atom) the read, or mark the component .hot()
```

The server `mount` accepts-and-ignores `V`, like `S` — there are no re-renders server-side. There is no
`{ loading }`-style discharge at `mount`: a boundary fallback makes no sense for scheduling.

## Prefer `defer(atom)` — lag the read a frame

`defer(a)` discharges `V` **at the read**: the value arrives through React's `useDeferredValue`, so a
burst of emissions coalesces instead of storming the tree. The label stays; on the server a deferred
read is a plain read. A `.derive` of a deferred atom is itself deferred — the lag survives projection.

```tsx
const Price = rec(function* () {
  const ticker = yield* Ticker;
  const price = yield* defer(ticker.price); // ✓ V discharged; still labelled 'price'
  return <b>{price}</b>;
});
```

Reach for it whenever the UI can lag a frame — which is almost always.

## Acknowledge a deliberately live leaf with `.hot()`

`.hot()` discharges `V` **at the component** — the `.catchDefect` of scheduling. It says: this subtree
is *meant* to render at source frequency (a ticker cell, a live chart leaf). Keep hot leaves small, and
it is deliberately not silent: in dev the ledger reports every `.hot()` subtree's render frequency.

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

`defer` when the UI can lag; `.hot()` only where source-frequency rendering is the point.

## Lock a component's render surface with `RendersOn`

`RendersOn<typeof C>` is the exact union of atom labels a component can re-render on. Lock it in a type
test — the repo convention is a two-way `extends`, so the lock is exact, not one-sided — and a new
subscription (a new reason to re-render) fails CI **by name**:

```tsx
import type { RendersOn } from '@tmonier/effract';

// checked by the compiler, not run — widen Dashboard's subscriptions and this line errors
const _lock: [RendersOn<typeof Dashboard>] extends ['stats' | 'filter']
  ? 'stats' | 'filter' extends RendersOn<typeof Dashboard>
    ? true
    : never
  : never = true;
void _lock;
```

Labels do not bubble through placements — a child's re-render does not re-render its parent — so the
lock names exactly what *this* component subscribes to.

## Ask the dev ledger why anything rendered

In development, every render is attributed to one of five causes — **atom** write, **query** settle,
**props** change, lifted-**hook** state, **parent** render — visible live in the
[`<Inspector />`](/docs/devtools/) trace, and readable without it:

```tsx
import { readRenderLedger } from '@tmonier/effract';

const last = readRenderLedger().at(-1);
last?.trigger; // { kind: 'atom', label: 'price' }
```

The ledger holds the last 200 renders, oldest first, and is always empty in production — the
instrumentation sits behind the same zero-cost gate as the rest of the [devtools](/docs/devtools/).

## Let the flip detector catch unstable yields

A raw `yield* effect` that resolves synchronously on one render and asynchronously on the next (a
service that caches after its first fetch) changes the component's hook count and breaks the Rules of
Hooks — a hazard the type system cannot see, because a sync and an async `Effect` are the same type. In
dev, the interpreter remembers how each yield resolved and reports a flip through the devtools seam
(`invariant:flip`), naming the REC and the offending yield. The fix: wrap the effect in
`suspend()`/`query()` so its loading is a stable hook, or make it consistently async.

## Memoize explicitly — `.memo()`, guided by the types

Placements are plain React: a parent re-render re-renders its children, and effract never memoizes for
you. When the ledger attributes re-renders to `props`, the explicit response is `.memo()` — and it
compiles only when the types prove it sound: every prop must be **value-comparable**, a primitive or a
value implementing the `Equal` trait. A function or JSX prop is a branded compile error naming the
offending key(s):

```tsx
class User implements Equal.Equal {           // structured data opts into value comparison
  constructor(readonly id: number, readonly name: string) {}
  [Equal.symbol](that: Equal.Equal) { return that instanceof User && that.id === this.id && that.name === this.name; }
  [Hash.symbol]() { return Hash.string(`${this.id}:${this.name}`); }
}
const Row = rec(function* (props: { id: number; user: User }) { /* … */ });
{yield* Row.memo().with({ id, user })}  // ✓ every prop is value-comparable

Toolbar.memo(); // Toolbar has an onPick: () => void prop
// ✗ effract: .memo() requires value-comparable props — hold functions in a service and
//   give structured data an Equal implementation; offending prop(s): 'onPick'
```

The comparator is bounded — per-key `Object.is`, then hash-gated `Equal` for trait values — never a deep
walk of a plain structure. The React Compiler memoizes for you, silently; effract refuses automagic: the
types tell you when memo is sound, and you say it.
