Production

The Elm Architecture at 62,000 lines

TypeFirst · AgileLabs Engineering · 11 min read

When we wrote "We banned React hooks", the most common objection was: fine for a demo — what happens at scale? Fair question. Here's the largest answer we have: a production operations dashboard we run at AgileLabs. Live server-pushed data over SSE, multi-step review workflows a whole team works through together, and embedded live video. The numbers:

Code in this post is from that codebase, trimmed and with domain identifiers renamed. The shapes, the counts and the TODOs are real.

The entire runtime

// Runtime/Internal.ts — this is the whole state manager
export type Action<S> = (state: S) => [S, Cmd<S>]
export type Cmd<S> = Array<Promise<Action<S> | null>>

export function start<S>(initState, initCmd, renderState) {
  let singletonState: S = initState
  // JS is single-threaded, so emit always runs sequentially
  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 state to [newState, effects]. Effects are promises of the next action. That's the entire mental model, and it's the same one on every page of a 62k-line app. No dependency arrays, no stale closures, no "why did this effect run twice" — because there are no effects except the ones you return.

One state tree, one field per feature

// State.ts (trimmed)
export type State = PublicState | AuthState

export type AuthState = Omit<PublicState, "_t"> & {
  _t: "Auth"
  profile: UserProfile       // exists ONLY when logged in
  board: BoardSSEState        // the live board this user is watching
  overview: OverviewSSEState
  caseManagement: CaseManagementState
  messaging: MessagingState
  notification: NotificationState
  currentDateTime: Timestamp
  // …one field per feature, ~20 total
}

Auth is a union variant, not a nullable field — the entire app's pages-behind-login are functions of AuthState, so "user is unexpectedly null" is not a bug this codebase can have. And connection state gets the same treatment; you cannot render a board you're not subscribed to:

// State/Board.ts
export type BoardSSEState =
  | { _t: "SUBSCRIBING" }
  | { _t: "ERROR"; message: ErrorCode }
  | BoardState  // _t: "SUBSCRIBED" — board info, cases, modals live here

With 350 update functions, re-checking "am I logged in? am I subscribed?" everywhere would be misery. Instead, tiny lenses do the narrowing once:

export function _AuthState(
  fn: (authState: AuthState) => [State, Cmd],
): Action {
  return (state) => (state._t === "Auth" ? fn(state) : [state, []])
}

An auth-only action wrapped in _AuthState is a no-op if the session expired mid-flight. There's a deeper one — _SubscribedBoardStateWithCase — that narrows three layers (logged in → board subscribed → case open) and shows a toast if the precondition broke. Race conditions between "user logged out" and "API response arrived" just… aren't.

Live data: the server emits actions too

This dashboard is mostly server-pushed: board updates stream in over SSE. The wiring is the best part — every decoded event goes through the same emit as a button click:

// Api/SSE.ts — bytes → decoder → runtime
events.forEach((event) => {
  const data = decodeEventData(event, streamDecoder)  // typed contract
  if (data !== null) {
    emit(onMessage(data))  // a server push is just an Action
  }
})

There is no second state system for "socket data" — no query cache with its own invalidation rules living beside your component state. One handler exhaustively switches over the ~35 event types the server can push, and when the backend adds a 36th, the frontend fails to compile until someone handles it. Even which connections should exist is derived from state, Elm-subscriptions style: after every render, a watcher diffs route + permissions and opens or closes SSE channels to match.

Timers work the same way. The header clock is an init action that aligns to the next minute, then a Timer.every emitting an update action — visible in the action log like everything else, no useEffect, no cleanup function to forget.

The escape hatch: a video player in a leaf

Not everything belongs in a state tree. The dashboard renders live H.264 video via WebTransport and WebCodecs — thousands of frames a second across a grid of panels. Routing frames through emit would re-render the app per frame. The answer is quarantine: one leaf component owns the imperative mess, with a ref callback as its entire lifecycle:

// View/LiveVideo.tsx — no hooks, one AbortController
function UnmemoLiveVideo(props: Props): JSX.Element {
  let controller: Maybe<AbortController> = null
  const onVideoMount = (el: Maybe<HTMLCanvasElement>) => {
    if (el) {
      controller = new AbortController()
      startStream(controller, el, props)   // decode → paint canvas
    } else if (controller) {
      controller.abort()                   // unmount → teardown
    }
  }
  return <canvas ref={onVideoMount} />
}

The boundary stays typed — stream errors arrive as decoded error codes, identity is memo with an explicit comparator on opaque IDs — but the 60fps hot path never touches TEA. Knowing what to keep out of your architecture is part of the architecture.

The honest costs

Scale exacts its price, and it's visible in the repo:

And yet: zero hooks, zero eslint-disable, zero ts-ignore, in 62,000 lines under deadline pressure. Not because the team is superhuman — because the lint config bans the escape hatches, so the pressure had nowhere to leak. The discipline isn't a culture document. It's machine-checked, which is the only kind of culture document that survives a deadline.

Try the architecture at small scale first. ts-bedrock ships the same runtime in its Web app — small enough to read in an afternoon. The backstory is in "We banned React hooks".

← All posts