The Hidden Cost of React Hooks
Monads, CPS, and the Effect System React Can't Have
A conversation about what React hooks really are, why they feel awkward, and what's missing from the language underneath. Created with Claude Opus 4.6.
Consider the following custom React hook:
import { useEffect, useState } from "react";
export const useResolve = (resolve) => (data, init) => {
const [state, setState] = useState(init);
const [error, setError] = useState();
useEffect(() => {
if (!data) return;
let cleanup;
try {
cleanup = resolve(data, setState, setError);
} catch (err) {
setError(err);
}
return () => {
if (typeof cleanup === "function") cleanup();
setState(init);
setError();
};
}, [data]);
return [state, error];
};This hook has a curried structure: useResolve(resolve)(data, init). It separates the strategy (how to resolve) from the invocation (what to resolve).
The resolve callback receives setState and setError directly rather than the hook awaiting a Promise. This supports synchronous resolution, streaming/incremental updates (call setState multiple times), subscriptions (return a cleanup function), and full error control. It is more general than the typical useAsync(promiseFn) pattern. The tradeoff is the caller bears more responsibility.
The hook expects resolve to optionally return a cleanup function, mirroring useEffect's own convention. On cleanup it also resets state to init and clears errors — treating each data change as a fresh lifecycle. This is opinionated: it assumes stale state from a previous data value should never linger.
useEffect depends only on data, meaning resolve and init are treated as stable references. This avoids unnecessary re-fires but is fragile — if a caller passes unstable references, the hook won't pick up changes.
The try/catch around resolve() only catches synchronous throws. Async errors must be reported via setError by the caller. This is consistent with the inversion-of-control design.
The currying isn't about reuse across components. It's about defining resolution strategies per monad type. The resolve function is an interpreter for a specific monadic wrapper.
// resolvers for different monadic types
const usePromise = useResolve((promise, setState, setError) => {
let cancelled = false;
promise
.then((v) => !cancelled && setState(v))
.catch((e) => !cancelled && setError(e));
return () => { cancelled = true; };
});
const useObservable = useResolve((obs$, setState, setError) => {
const sub = obs$.subscribe({ next: setState, error: setError });
return () => sub.unsubscribe();
});
const useEventSource = useResolve((url, setState, setError) => {
const es = new EventSource(url);
es.onmessage = (e) => setState(JSON.parse(e.data));
es.onerror = (e) => setError(e);
return () => es.close();
});Usage becomes uniform regardless of the underlying monad:
const [user, userErr] = usePromise(fetchUser(id), null);
const [clicks, clickErr] = useObservable(click$, 0);
const [feed, feedErr] = useEventSource("/api/stream", []);useResolve is the higher-kinded abstraction. It doesn't care what data is, only that the resolver knows how to unwrap/subscribe to it and funnel values through setState. Each resolver is essentially M a → (a → IO ()) → IO cleanup.
A controlled React input is essentially (value, setValue) :: (a, a → IO ()) — an IORef a. At first glance, it's not monadic in the same way Promise or Observable are, because there's no "unwrapping" to do.
But useState's state is already a stream. Every setState call produces a new value on the next render. The render cycle is the subscription mechanism. So a controlled input is already Observable a where React's reconciliation loop is the scheduler.
This means you can feed one component's state directly into useResolve:
const [query, setQuery] = useState("");
const [results, error] = usePromise(
useMemo(() => query && fetchSearch(query), [query]),
[]
);
<input value={query} onChange={(e) => setQuery(e.target.value)} />query changes → data changes → effect fires. The input's state stream is consumed by useResolve through React's own reactive system. No Observable wrapper needed.
Every hook is essentially ((a → UI) → UI) — the continuation monad. useState captures "give me the current value and I'll tell you what to render." useEffect captures "give me the opportunity to perform side effects around the render." The component function itself is the continuation that React invokes repeatedly.
The render cycle is the monad runner, setState is shift, and React's scheduler is reset. Suspense makes this even more explicit — it literally throws a Promise to abort the current continuation and resumes it later.
The PL crowd mostly discusses React in terms of algebraic effects (which is what the React team themselves reference). CPS and delimited continuations are the operational semantics of algebraic effects, so it's the same insight from a different angle — just one that maps more directly onto what hooks actually do at the call-site level.
Delimited continuations capture a slice of the call stack, and closures capture variables from their lexical scope. When you combine them, continuations hold references to closure-captured variables, and closures reference frames inside captured continuations. The ownership graph becomes a mess.
In a GC'd language like JS, this is "just" a GC pressure problem — captured continuations keep entire closure chains alive, and the GC can't easily determine when a captured continuation will never be resumed.
The real problem is that V8/SpiderMonkey/JSC are heavily optimized around the assumption that the call stack is linear and ephemeral. Closures work because they only capture variables, not control flow. The engine can stack-allocate frames and optimize escape analysis around this. Delimited continuations break that assumption — a stack frame might need to be copied, suspended, resumed, multiplied. Suddenly you need heap-allocated frames, which defeats a huge category of JS engine optimizations.
Generators and async/await got in because they are restricted forms — single suspension point, known resume pattern. General delimited continuations are a much bigger ask.
OCaml 5 uses unboxed, one-shot continuations — a continuation can only be resumed once. This makes memory management tractable: no copying of stack frames, just moving them. Ownership is linear, no duplication, no GC ambiguity. The tradeoff: you can't implement backtracking or nondeterminism directly, since those require multi-shot continuations.
call/cc gives you full, multi-shot, undelimited continuations. It works because Scheme already heap-allocates everything and the GC handles it. But it's notoriously hard to optimize and reason about.
Algebraic effects are encoded via monads and free/freer monads at the type level. There are no actual captured stack frames. The continuation is a data structure built by the program, interpreted by a handler. Zero runtime magic, but the performance cost is in the encoding.
Fibers — virtual threads with heap-allocated stacks that the runtime can suspend and resume. More restricted than general delimited continuations but covers the async use case.
Nobody ships general multi-shot delimited continuations with good performance. Everyone picks a restriction — one-shot (OCaml), virtual threads (JVM), monadic encoding (Haskell), or limited suspension (JS generators). OCaml's one-shot approach is probably the most elegant engineering compromise: real effect handlers with minimal runtime cost, and the linearity constraint is natural for most practical effects (state, IO, exceptions, async).
Hooks are a manual, leaky encoding of an effect system that the language can't express and the framework can't fully automate. Every useCallback, every useMemo, every Rules of Hooks lint error, every stale closure bug — these are all symptoms of encoding continuations by convention in a language that doesn't support them.
The useResolve hook exists because React has no generic way to say "unwrap this effectful container into state." If React had real algebraic effects, useResolve would just be a handler declaration. If JS had delimited continuations, the Rules of Hooks wouldn't need to exist. The manual ceremony is the cost of the missing infrastructure.
use lets you unwrap a Promise or Context inline without the useEffect + useState dance. It's sugar over Suspense, not a new capability. You still can't write a generic use that handles Observable, EventSource, or any arbitrary monadic type — it's hardcoded to Promise and Context.
Which is exactly what useResolve solves. use gives you one interpreter for one monad. useResolve gives you a factory for interpreters over any monad. React chose to special-case the common case rather than expose the general mechanism.
And use still has the same underlying problems — still convention-based, still relies on Suspense as the monad runner, still can't express cleanup or cancellation natively. The useResolve hook's cleanup return is more expressive than anything use offers.
The ceremony in React hooks isn't inherent to UI programming. It's an accident of implementation — the cost of encoding continuations by convention in a language that will likely never support them natively, with a framework that wants to be an effect system but can't commit.
Understanding this doesn't necessarily help you ship features faster. But knowing that the awkwardness has a reason — and that the reason is a fundamental gap between the theoretical model and the runtime — is worth knowing early in your career. It changes how you evaluate new APIs, how you design abstractions, and how you decide what's worth fighting against versus accepting as a constraint.