The Elm Architecture, without Elm | Nobuharu ShimazuThe Elm Architecture, without Elm<br>July 23, 2026 · 8 min · 1658 words · Nobu Shimazu<br>Table of ContentsThe problem TEA solves<br>The four pieces<br>It’s the modern default now, just under other names<br>Crave has no framework, so I built the loop myself<br>Two ways in, one way through<br>What I gave up
The problem TEA solves#<br>Crave is a recipe manager I wrote in C. There’s no framework under the drawing library, no React, no GTK, no SwiftUI. It’s just Clay laying out rectangles and raylib putting pixels on screen, once a frame forever until the window closes.<br>Once you’re redrawing everything 60 times a second instead of mutating a DOM tree, bugs stemming from state changes from three places that don’t know about each other: a click handler mutates a field; a keyboard shortcut mutates the same field a different way; a database callback mutates it a third way, on a frame where the first two also fired. Nothing crashes. The screen just shows something nobody asked for, and you spend an evening adding print statements to figure out which of the three writers won that frame.<br>The Elm Architecture, TEA for short, structures an app so that question doesn’t come up, because there’s only ever one writer.<br>The four pieces#<br>TEA splits an app into four things and data only flows one direction through them.<br>┌──────────────┐<br>│ Model │ all your app state, one value<br>└──────┬───────┘<br>┌──────────────┐<br>│ view │ Model -> what's on screen<br>└──────┬───────┘<br>│ user clicks / types<br>┌──────────────┐<br>│ Msg │ a description of what happened<br>└──────┬───────┘<br>┌──────────────┐<br>│ update │ (Model, Msg) -> new Model<br>└──────┬───────┘<br>└──────────────┐<br>back to Model<br>Model is one value that holds everything: which screen you’re on, what’s typed into every field, what’s loading. Not scattered across components, one value.<br>view is a pure function from Model to what the screen shows. It doesn’t mutate anything. It just describes.<br>Msg is data, not a function call. A button doesn’t say “delete this recipe right now.” It says “here’s a Msg describing a delete click, do with it what you will.”<br>update is the only place a new Model gets built. It pattern matches on the incoming Msg and returns the next Model. That’s its whole job.<br>The loop closes on its own. view renders the Model and wires up which Msg each interaction produces. The user does something. Then update folds that Msg into a new Model. view runs again on the result. No setState calls sprinkled through click handlers, because click handlers never get to touch state directly. They only get to emit a Msg and hand it off.<br>This is Elm’s actual architecture, hence the name, and Evan Czaplicki wrote up the canonical version, commands and subscriptions included, here. What I’m describing below is the stripped-down version I actually use.<br>It’s the modern default now, just under other names#<br>TEA sounds a little academic, a functional-programming thing, and a decade ago when Elm was making the case for it, it kind of was. What’s happened since is most of the UI world landed on the same shape from a completely different direction.<br>SwiftUI’s View is a pure function of @State, and @State only changes through explicit bindings, not mutation from wherever you feel like. Jetpack Compose is the same idea with different keywords: composables are pure functions of state, state hoisting pushes the source of truth up to one place, and a composable re-runs instead of being told to mutate itself. React, once you’re on hooks, and not reaching for a ref to sneak around the model, is doing the same thing, too. A component is a function of state, and setState schedules a new rendr rather than mutating in place.<br>None of these call themselves TEA and they all disagree on small details. Elm’s Msg and update are explicit and easy to inspect in a way React’s setState calls usually aren’t, and Elm gives you no mutation escape hatch at all, but Swift and Kotlin very much do. But once your rendering is declarative, once you’re describing what the screen should look like instead of issuing draw calls by hand, you need state changes to be legible and centralized, or the whole “describe, don’t mutate” contract falls apart the first time two things want to touch the same value on the same frame. TEA stopped being a framework you opt into a while ago. It’s become closer to just the shape UI state ends up in once rendering stops being imperative.<br>Crave’s rendering never stopped being imperative. Clay and raylib redraw the whole tree every frame no matter what changed, no diffing, no reconciler. So, I didn’t inherit TEA from a framework enforcing it. I picked it because the alternative was a raylib app full of unguarded mutation, and I’d already been burned by that shape once on an earlier...