Frontend

We banned React hooks — here's what happened

TypeFirst · AgileLabs Engineering · 11 min read

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

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:

The honest costs:

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.

Read the runtime yourself — it fits in one screen: Web/src/Runtime/Internal.ts in ts-bedrock, with a full login/profile app built on it.

← All posts