Frontend
We banned React hooks — here's what happened
Yes, all of them. useState, useEffect, useMemo, useCallback, your favorite custom hook — all lint errors in our codebases, enforced in CI with zero tolerated warnings. We still use React. We use it for exactly one thing: turning state into DOM.
Before you close the tab: this is not a "React is bad" post. React's renderer is excellent. Our problem was never rendering — it was that hooks turn every component into a little stateful machine with its own hidden lifecycle, and a real app becomes hundreds of those machines whispering to each other through effects.
What we kept losing with hooks
- The answer to "what is the state of my app?" With hooks, app state is smeared across component instances, refs, contexts, and whatever your data-fetching library caches. No single value you can log, snapshot, or reproduce a bug from.
- Predictable updates.
useEffectis an implicit lifecycle: it runs after render, if the dependency array changed, unless a stale closure captured the old value. Entire conference talks exist to explain when an effect fires. That's not a learning curve; that's accidental complexity. - Testability without a DOM. Logic in hooks is welded to the renderer. Testing a state transition means mounting components, faking timers, and awaiting
act()— to test what is, conceptually, a pure function.
What we do instead: 50 lines of Elm
The Elm Architecture has one moving part. There is a single state value. The only way it changes is through an action. Here is the complete heart of the ts-bedrock runtime — this is not pseudocode:
// Web/src/Runtime/Internal.ts
export type Action<S> = (state: S) => [S, Cmd<S>]
export type Cmd<S> = Array<Promise<Action<S> | null>>
export function start<S>(
initState: S,
initCmd: Cmd<S>,
renderState: (state: S) => void,
): EmitFn<S> {
let singletonState: S = initState
const emit = (action: Action<S>) => {
const [newState, cmd] = action(singletonState)
singletonState = newState
runCmd(emit, cmd)
renderState(singletonState)
}
runCmd(emit, initCmd)
renderState(singletonState)
return emit
} An Action is a pure function from old state to [new state, side effects]. Side effects (Cmd) are promises that may resolve to another action — that's how an API response re-enters the loop. React's only job is renderState: the whole component tree is a pure function of the singleton state, and components communicate upward exclusively by emitting actions.
// A login submit, end to end
export function onSubmit(params: BodyParams): Action {
return (state) => [
_LoginState(state, { submitting: true }),
[LoginApi.call(params).then(onSubmitResponse)],
]
} Read what that buys you. The state change and the side effect are values returned from a pure function. To test the entire submit flow you call onSubmit(params)(someState) and assert on the tuple. No renderer, no mocks, no act().
What happened
The good — and it's very good:
- Debugging collapsed into one question: "which action produced this state?" Log every action and you have a perfect timeline of everything the app ever did. Race conditions from overlapping effects simply stopped appearing — there is one state, and JavaScript's single thread updates it sequentially.
- Impossible UI states became compile errors. The state is one big sum type — being logged in without a user isn't a bug to test for; it doesn't typecheck.
- Onboarding got easier, not harder. Counterintuitive but consistent: juniors learn "state in, state out" in a day. They learn it faster than dependency arrays, because there is nothing implicit to memorize. There is no "you just have to know" list.
- React version upgrades became boring. We use no lifecycle APIs, no context, no concurrent-mode-sensitive features. The renderer is a peripheral.
The honest costs:
- You're off the ecosystem road. Most modern React libraries are hooks-first. Component libraries that render UI work fine; anything that wants to own your state (TanStack Query, form libraries, most animation state helpers) doesn't fit. We write more of our own plumbing — forms, data fetching, routing glue. For an agency with a shared template that's a one-time cost amortized across projects; for a solo project it's real friction.
- Performance needs honesty. Every action re-renders from the top (with
flushSync, so state and DOM never disagree). React's diffing makes this fine for the dashboards and admin apps we build; for a 60fps canvas or a giant virtualized table you'd need memoized subtrees or a different approach. We have not needed one yet — but we also don't build games. - Hiring requires a pitch. Every React dev we interview knows hooks; none know TEA. That's exactly why the Learn curriculum exists — the architecture is small enough to teach in a week because there's only one pattern in the entire frontend.
Should you do this?
If you're happy with hooks, genuinely: carry on. This trade is for teams that have been burned by untraceable state bugs, that value "boring and predictable" over ecosystem velocity, and that can invest a week of onboarding to get years of debuggability. We made the bet on client projects where someone else maintains the code after us — and "the entire app is one state value and a list of pure functions" is the kindest handover document we know how to write.