---
title: Hooks are algebraic effects
description: React's team already knew hooks were algebraic effects; effract runs the interpreter inside React's render pass instead of forking the reconciler.
group: Start
order: 7
---

Here is a claim the React team has made about its own runtime, more or less out loud: **hooks are
algebraic effects.** Sebastian Markbåge has framed React in exactly these terms — fibers, coroutines,
algebraic effects — and Dan Abramov's _"Algebraic Effects for the Rest of Us"_ spells out the analogy
for everyone else. It is worth taking seriously, because if it is true, it tells you what React
_wants_ to be.

## Hooks are effect operations React interprets

An algebraic effect is a two-part deal: code **performs** an operation, and a **handler** somewhere up
the stack decides what that operation means. The performing code doesn't run the effect itself; it
names it and hands control to whoever is interpreting.

That is precisely what `useState` and `useEffect` are. When a component calls `useState(0)`, it isn't
allocating state — it's _performing an operation_ that the React runtime, the handler, interprets:
React associates the call with a slot on the current fiber, remembers it across renders, and hands
back the current value. `useEffect` performs "schedule this after commit." The component describes
what it needs; React decides how and when. The Rules of Hooks — stable call order, only at the top
level — are the algebraic-effects contract leaking through: the handler identifies each operation by
its **position**, so the positions must not move.

So React already has an effect system. It just doesn't have effect _values_ — the operations are
untyped, invisible to the compiler, and expressible only through a fixed set of built-in `use*`
functions.

## The generator road, and why React didn't take it

If hooks are effect operations, the obvious language-level substrate is a **generator**: a function
that `yield`s an operation, suspends, and resumes with the answer the handler supplies. Coroutines are
the textbook implementation of algebraic effects. React explored this internally — components as
generators / coroutines — and walked away from it.

The reason, honestly stated, is **coloring**. A generator is contagious the same way `async` is: the
moment one function in a call chain is a generator, every caller that wants its result has to know,
`yield*` into it, and become generator-shaped too. It spreads up the tree. "What color is your
function" is not a JavaScript-only tax; it's a property of any control-flow mechanism that changes a
function's calling convention, and generators change it. Inside a framework meant to be picked up
incrementally and composed everywhere, viral coloring was a dealbreaker. So React kept the effect
_semantics_ (hooks) and dropped the effect _syntax_ (generators). Fair trade, given the constraint.

## Crank and effect-ui paid for generators with the reconciler

Other projects decided the generator ergonomics were worth having. **Crank.js** makes components
generators outright; several **effect-ui**-style experiments wire Effect into a component tree the same
way. They get the thing React gave up — a component that `yield`s.

They pay for it in the wrong currency: a **forked renderer.** To make generator components schedulable,
each ships its own reconciler. And the moment you leave React's reconciler, you leave React. No React
DevTools. No Suspense — not the real one, with its commit-phase retry semantics. No Server Components,
no streaming SSR with selective hydration, no `useTransition`, no concurrent features. No ecosystem: the
thousands of components, hooks, and libraries that assume they're mounted in a React tree. You've bought
`yield` and sold everything downstream of it. For most teams that is not a trade — it's a rewrite with a
smaller island at the end.

## The effract move: the interpreter goes _inside_ render, not around it

The tension only looks unresolvable if you assume the interpreter has to _own_ the render loop. It
doesn't. effract puts the interpreter **inside React's render pass** — it cooperates with React's
reconciler instead of replacing it.

A React Effect Component (REC) body is a **plain generator, not an `Effect`.** (A real `Effect.gen`
would run on an Effect fiber, asynchronously, _outside_ render — and any hook it called would violate
the Rules of Hooks.) effract supplies its own interpreter that drives that generator **synchronously,
top-to-bottom, during render**, and answers each `yield*` by inspecting the instruction:

- **A lifted hook** — `yield* hook(useState(0))`. The hook is called _inline_ as the yield expression
  evaluates; `hook(...)` only lifts the already-computed value into the yield channel. Because the
  interpreter steps the generator in the same order on every render, these stay genuine React hooks
  with a stable position — the Rules of Hooks hold.
- **A service / synchronous effect** — `yield* Stats`. Resolved with `ManagedRuntime.runSyncExit`
  against the runtime built once at the `<Runtime>` boundary. A service read is a synchronous `Context`
  lookup — no async cost at the call site.
- **An asynchronous effect** — detected when `runSyncExit` comes back a failure whose squashed cause is
  Effect's `AsyncFiberError`. effract runs the effect as a promise, caches it per component instance by
  encounter order, and calls **React's `use`** to suspend. The Suspense retry re-runs the generator from
  the top, hits the cache, and returns the value inline.
- **A genuine failure** — any other failed exit is rethrown, surfacing at the nearest React error
  boundary.

The interpreter lives in the application layer and is **React-free**: it takes React's `use` and the
Effect executor through ports, so the whole fiber bridge is unit-tested with plain fakes, no renderer.

The elegance is that there is one channel. React schedules work on units it calls **fibers**; Effect
schedules work on units it also calls **fibers**. A single `yield*` protocol — the same single-shot
iterable protocol Effect's own `Effect.gen` uses — speaks to both. `yield* Stats` and
`yield* hook(useState(0))` come out of the same generator, and `E`/`R` are recovered by the same
conditional-type inference. It solves the coloring problem structurally: the generator is contained to
the REC body and never leaks its calling convention up the tree, because React, not your callers, is
the handler. And it is **100% real React** — hooks keep their order; Suspense, error boundaries,
memoization, hydration, and concurrent features all work, because effract renders _through_ React
rather than around it. (The full design is [ADR 0001 — Fiber
reconciliation](https://github.com/get-tmonier/effract/blob/main/docs/adr/0001-fiber-reconciliation.md).)

## The payoff: obligations the compiler makes you discharge

Once effect operations are typed _values_ instead of invisible `use*` calls, the type system can hold
you to them. A REC's failures ride in Effect's error channel `E`; its pending loads ride in a loading
channel `S`. Both bubble up the tree the way service requirements do — and **`mount` will not compile
until both are discharged.**

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

const Profile = rec(function* () {
  const user = yield* query(fetchUser(id), id); // E = NotFound · S = Suspends
  return <Card user={user} />;
})
  .catch({ NotFound: () => <Empty /> }) // errors → UI, exhaustive per _tag
  .suspense(<Spinner />); // loading → UI, discharges S

mount(App, Profile); // compiles only because E and S are both handled
```

Drop the `.catch` and it won't compile — the `NotFound` your body can fail with is an undischarged
member of `E`. Invent a tag the body can't raise and it won't compile either — an excess property.
Drop the `.suspense` and the loading obligation bubbles all the way to the root and stops `mount` at
the door. This is what hooks-as-algebraic-effects buys you the moment the effects become typed: the
compiler makes you handle the loading and error states React lets you silently forget.

And because "where it runs" is a property of the Effect runtime rather than of the component, the same
body and the same `mount(layer, Root)` render in a SPA, in streaming SSR, and — resolve-up-front — as
a React Server Component; the bundler's `react-server` export condition picks the client or server
`mount`. **Server vs client is never something you type.**

## It's out

effract is on npm today:

```bash
npm i @tmonier/effract
```

Open the [StackBlitz
playground](https://stackblitz.com/github/get-tmonier/effract/tree/main/playground) to try a REC in the
browser without installing anything. MIT licensed; requires **React 19** and **Effect v4** (installed
as peers). Hooks were always algebraic effects — effract just gives them a type and an interpreter, and
leaves the reconciler exactly where React put it.
