---
title: Reactivity
description: State and logic live in Effect services; React only renders. The atom toolkit — atom, yield*, derive, atomFamily, batch — and the hooks that read a local one.
group: Core
order: 3
---

The point of effract: **state, and the logic over it, live in Effect services; React only renders.** No
`useState` threading business rules through a component, no derivation recomputed on every render. A
component reads a service with `yield*` and returns JSX — that is the whole job.

## State in a service

Put reactive state — an `atom` — _inside a service_, so it is reachable from anywhere an Effect runs
(other components, background fibers, the server). Derive values from it _there_, with `derive`, and
expose methods that mutate it. The component only reads and renders.

Every atom is **named** — `atom(label, initial)`, label first, exactly like a service Tag. That one
literal is the cell's name everywhere: the devtools trace, the SSR key, and the type-level
[`RendersOn`](/docs/render-control/) set of labels a component can re-render on.

```tsx
import { atom, rec } from '@tmonier/effract';
import * as Context from 'effect/Context';
import * as Effect from 'effect/Effect';
import * as Layer from 'effect/Layer';

// The service shape is inferred from `make`; the Live layer is a `static`.
class Cart extends Context.Service<Cart>()('app/Cart', {
  make: Effect.sync(() => {
    const items = atom('items', [] as ReadonlyArray<Item>); // label first, like a service Tag
    const total = items.derive((list) => list.reduce((n, i) => n + i.price, 0)); // derived
    return { items, total, add: (item: Item) => items.update((xs) => [...xs, item]) };
  }),
}) {
  static readonly layer = Layer.effect(Cart, Cart.make);
}

const CartSummary = rec(function* () {
  const cart = yield* Cart;
  const total = yield* cart.total; // read + subscribe — re-renders only when total changes
  return <button onClick={() => cart.add(coffee)}>add · ${total}</button>;
});
```

No `useState`, no logic in the component — pure render + events.

## Reading an atom — `yield*`

`yield*` an atom in a REC to read its current value **and** subscribe this component to it: it re-renders
precisely when that atom changes, and nothing else does. No hook, no selector, no `Effect.runSync`.

```tsx
const n = yield* cart.count;
```

Read one imperatively (in an event handler or a service method) with `.value`; write it with `.set` /
`.update`.

## `derive` — computed state

For a value computed from **one** atom, use `atom.derive` — the value is handed to you directly, and it
chains:

```tsx
const count = items.derive((list) => list.length);
const total = items.derive((list) => list.reduce((n, i) => n + i.price, 0));
const withTax = total.derive((t) => Math.round(t * 1.1)); // chains off a derived atom
```

For a value computed from **several** atoms, `derive` takes its sources as an **explicit tuple** and
hands you their current values, position by position:

```tsx
const subtotal = derive([price, qty], ([p, q]) => p * q); // two sources
```

The tuple is the subscription set *and* the type-level fact: the derived atom recomputes — and its
readers re-render — precisely when a source changes, and it **inherits** the union of its sources'
labels and volatility with zero annotation. Derived cells are never manually named — a
`derive([price, qty], …)` re-renders on `'price' | 'qty'`, and a derive of a
[volatile](/docs/render-control/) atom stays volatile. There is no dependency *tracking* to reason
about: what you list is what it reads. (Dynamic tracking survives only in `observe`/`<Observe>`, which
are JSX-level observation.)

### `derive.writable` — two-way

A computed atom you can also `set`; the write flows back to the sources. For adapters and projections — a
form field over a model, a unit conversion.

```tsx
const fahrenheit = derive.writable(
  [celsius],
  ([c]) => c * 1.8 + 32,
  (f) => celsius.set((f - 32) / 1.8),
);
```

### `derive.effect` — async computed

Takes explicit source atoms and returns an Effect over their values. Read in a REC it **suspends** while
the effect runs and refetches when a source changes — carrying the effect's `E`/`R` and the same loading
obligation `S` as [`query`](/docs/loading), which `.suspense(fallback)` discharges.

```tsx
const price = derive.effect([sku], ([s]) => fetchPrice(s)); // suspends; refetches when sku changes
```

## `atomFamily` — per-entity state

One lazily-created, memoised atom per key — per-row / per-item state as a lookup rather than one giant
atom you slice. Each reader subscribes to its own atom.

```tsx
const quantityOf = atomFamily('quantities', (id: string) => atom(id, 1));
quantityOf('sku-1').update((n) => n + 1); // independent per id — traced as `quantities(sku-1)`
```

By default a family keeps every entry strongly. For a family keyed by **unbounded dynamic keys** (a
per-visited id or route), opt into reclamation with `gc`:

```tsx
// drop an entry once its atom has no subscribers left:
const rowState = atomFamily('rows', (id: string) => atom(id, false), { gc: 'unreferenced' });
// or cap it as a bounded LRU:
const recent = atomFamily('recent', (id: string) => atom(id, id), { gc: { maxSize: 100 } });
```

`'unreferenced'` reclaims an entry when its last reader unsubscribes (StrictMode-safe); `{ maxSize }`
evicts the least-recently-accessed beyond the cap. `keyOf` (for non-primitive keys) and `gc` are both
options: `atomFamily(label, make, { keyOf, gc })`.

## `batch` — coalesced writes

> **`batch` is synchronous-only.** Its callback coalesces a burst of _synchronous_ writes; an `await`
> inside would leave the batch open across the suspension point (on a server, interleaving with other
> requests). Do the async work first, then `batch(...)` the writes it produced. In development, a
> callback that returns a `Promise` throws.

Coalesce a burst of writes into a single notification wave — a derived value reading two atoms both
changed recomputes once, its readers re-render once.

```tsx
batch(() => {
  first.set('Ada');
  last.set('Lovelace');
}); // one re-render, not two
```

## Read an atom from a plain component

A plain React component does not have to become a REC to read service state. The hooks give it the
same precise subscription a `yield*` gives a REC — this is the incremental path: state moves into a
service first, components migrate one at a time.

```tsx
function Badge() {
  const n = useAtomValue(cart.count); // subscribes this component to one atom
  const [draft, setDraft] = useAtom(cart.draft); // read + write, the useState shape
  return <b>{n}</b>;
}
```

`useAtomValue` (read-only) and `useAtomSet` (a stable setter) are the two halves of `useAtom`. Inside a
REC, never reach for these — `yield*` the atom.

`<Observe>` reads an atom **inline in JSX** — sugar for the one-slot read. In a REC, `yield* atom`
subscribes the whole body; `<Observe>` subscribes just the slot, with no `yield*` and no hook, so the
component around it never re-renders:

```tsx
const Header = rec(function* () {
  const cart = yield* Cart; // a service read — not a subscription
  return (
    <header>
      …heavy static markup…
      <Observe>{($) => <b>{$(cart.count)}</b>}</Observe> {/* only this slot re-renders */}
    </header>
  );
});
```

Its `$` tracks whatever atoms the expression reads at runtime — the one place runtime tracking
survives, because a JSX slot has no type-level channel to lie about. A slot read this way is not part
of the component's own subscription set (`RendersOn` won't list it — correctly: the component doesn't
re-render).

For state that is genuinely local to one component instance, an atom is the wrong tool — atoms are
created at module or service scope, so every instance would share one cell. Use `hook(useState)` in a
REC, or plain `useState` in a plain component.

