Production
The Elm Architecture at 62,000 lines
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:
- 347 TypeScript files, ~62,000 lines in
Web/src - 62 action files exporting ~350 update functions
- 0 React hooks —
grep useState|useEffect|useRef|useMemofinds nothing - 0
eslint-disable, 0@ts-ignore, in the entire frontend - and a runtime that fits on one screen.
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:
- Update logic centralizes into big files. The largest action file is ~1,200 lines, and the SSE dispatcher opens with a confessional
// TODO: This file should be splited to multiple files. When all logic lives in actions, actions are where the bulk goes. - No context means real prop drilling. One detail card takes eleven props threaded down from
AuthState. Updating a nested modal field is a triple-nested spread. Elm has this pain too; TypeScript's structural types soften it only slightly. - The runtime trades scheduler for determinism. It calls
flushSyncper action — synchronous, predictable, and slower than React's batching; performance is clawed back manually withmemo. - A few module-level mutables leaked in — the SSE-subscription differ tracks the previous channel outside the state tree, with an honest TODO attached. Elm's runtime would manage this; a 55-line one doesn't.
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.