There is a pattern that keeps showing up in places you would not expect. A Rust terminal app. A Scala backend processing commands. A game engine’s main loop. An embedded device polling sensors. They all converge on the same three-piece structure: a Model that holds the state, an Update function that transforms it, and a View function that renders it. No callbacks reaching into shared mutable state. No observer chains firing in unpredictable order. Just a loop.
This pattern has a name: The Elm Architecture (TEA). And despite the name, you do not need the Elm language to use it. You do not even need a frontend. The pattern is language-agnostic and domain-agnostic. It is a way of organizing any program that reacts to external events and needs to project state onto some output.
This post explains the pattern from scratch, with examples in Rust and Scala.
Where it comes from #
In 2012, Evan Czaplicki created Elm, a functional language that compiles to JavaScript for building web interfaces. Elm was opinionated: no null, no exceptions, no mutable state. The language forced every application into a specific shape:
- Define a Model: a data structure holding all application state.
- Define an Update function: given the current model and a message (an event), return a new model.
- Define a View function: given the current model, return the UI description.
The runtime handles the rest. It calls View to render, captures user events as messages, feeds them to Update, gets a new model, calls View again. Forever.
This was not marketed as an architecture pattern. It was just how Elm programs worked. But developers noticed something: the pattern was portable. You could apply the same structure in Rust, in Scala, in any language where you controlled the main loop. The name stuck: The Elm Architecture.
The three pieces #
Let us build a mental model before touching any code.
Model #
The Model is a single data structure that contains everything the application needs to render and to decide what to do next. Not some of the state. All of it.
In a counter app, the model is one number. In a todo list, it is a list of tasks plus a filter plus whatever the user is currently typing. In a game, it is the positions of all entities, the score, the level, and the input state.
The key rule: the Model is the single source of truth. There is no separate state hiding in a callback closure, in a global variable, or in a mutable singleton. If it is not in the Model, it does not exist.
Update #
The Update function receives two things: the current Model and a Message (sometimes called an Action or Event). It returns a new Model.
That is the entire contract. The function does not touch the screen, does not read user input, does not call an API. It receives data, returns data. A pure function in the functional programming sense (or as close to pure as your language allows).
Messages are typically modeled as a sum type: an enum in Rust, a sealed trait (or enum since Scala 3) in Scala. Each variant represents something that happened. The user pressed a key. A timer fired. A network response arrived. The Update function matches on the variant and decides how the model changes.
View #
The View function receives the current Model and returns a description of the output.
It does not mutate the model. It does not perform side effects. It reads the model and describes what the screen should look like. Whether that description is terminal widgets, HTML, or a line of text depends on the platform.
The critical property: the View is a projection. Given the same model, it always produces the same output. If you want to know what is on screen, look at the model. If you want to change what is on screen, change the model. The View is just the lens.
The loop #
The three pieces connect in a cycle:
┌──────────────────────────────────┐
│ │
▼ │
Model ──▶ View ──▶ Screen │
│ │
user event │
│ │
▼ │
Message ──▶ Update─┘- The Model is passed to View, which renders the screen.
- The user does something (clicks, types, presses a key). This produces a Message.
- The Message and the current Model go into Update, which returns a new Model.
- Go to step 1.
That is the entire architecture. No event bus. No observer pattern. No two-way data binding. No dependency injection container. One loop, three functions, one data structure.
A concrete example: a counter #
Let us see the pattern applied to the simplest possible application: a counter with increment, decrement, and reset. Side by side in Rust and Scala.
In Rust #
// -- Model --
struct Model {
count: i32,
}
// -- Message --
enum Msg {
Increment,
Decrement,
Reset,
}
// -- Update --
fn update(model: Model, msg: Msg) -> Model {
match msg {
Msg::Increment => Model { count: model.count + 1 },
Msg::Decrement => Model { count: model.count - 1 },
Msg::Reset => Model { count: 0 },
}
}
// -- View --
fn view(model: &Model) -> String {
format!("Count: {}", model.count)
}The Model is a struct. The Msg is an enum. The update consumes the old model and returns a new one (ownership transfer: the old state is gone, the new state takes its place). The view borrows the model and produces a description. No mutation, no side effects.
In Scala #
// -- Model --
case class Model(count: Int)
// -- Message --
enum Msg:
case Increment, Decrement, Reset
// -- Update --
def update(model: Model, msg: Msg): Model = msg match
case Msg.Increment => model.copy(count = model.count + 1)
case Msg.Decrement => model.copy(count = model.count - 1)
case Msg.Reset => Model(count = 0)
// -- View --
def view(model: Model): String =
s"Count: ${model.count}"The Model is a case class. The Msg is a Scala 3 enum. The update uses copy to derive a new model from the existing one, which is the standard immutable update pattern in Scala. The old model is untouched. The view is a pure function.
Walking through the loop #
Starting state: Model { count: 0 } in Rust, Model(count = 0) in Scala.
- View renders:
"Count: 0". - User sends
Increment. - Update receives the model and
Increment. ReturnsModel { count: 1 }. - View renders:
"Count: 1". - User sends
Incrementagain. - Update returns
Model { count: 2 }. - View renders:
"Count: 2". - User sends
Reset. - Update returns
Model { count: 0 }. - View renders:
"Count: 0".
At every step, the model is the complete truth. The view is a deterministic function of it. The update is a deterministic function of the model and the message. There is no hidden state, no order-dependent side effects, no “but what if the user clicked before the previous render finished.”
Wiring the loop #
The examples above define the pieces. The loop itself is straightforward. Here is a minimal version for each language.
Rust (a simplified terminal loop reading from stdin):
fn main() {
let mut model = Model { count: 0 };
loop {
println!("{}", view(&model));
println!("[+] increment [-] decrement [r] reset [q] quit");
let mut input = String::new();
std::io::stdin().read_line(&mut input).unwrap();
let msg = match input.trim() {
"+" => Msg::Increment,
"-" => Msg::Decrement,
"r" => Msg::Reset,
"q" => break,
_ => continue,
};
model = update(model, msg);
}
}Scala (using @tailrec for a stack-safe loop):
import scala.annotation.tailrec
import scala.io.StdIn
@tailrec
def loop(model: Model): Unit =
println(view(model))
println("[+] increment [-] decrement [r] reset [q] quit")
StdIn.readLine().trim match
case "+" => loop(update(model, Msg.Increment))
case "-" => loop(update(model, Msg.Decrement))
case "r" => loop(update(model, Msg.Reset))
case "q" => ()
case _ => loop(model)
@main def run(): Unit = loop(Model(count = 0))Both loops do the same thing: render, read, dispatch, repeat. The Rust version uses a mutable binding (let mut model) reassigned on each iteration. The Scala version uses tail recursion with the new model as argument, keeping everything immutable. Different idioms, same structure.
TEA in a real Rust TUI #
The counter above is a toy. In a real application, the pattern scales naturally.
In the Todo TUI in Rust series, we built an interactive task manager using ratatui. The mapping to TEA was direct:
| TEA concept | Implementation | File |
|---|---|---|
| Model | App struct (tasks, filter, input buffer, InputMode) |
app.rs |
| Update | App methods called from the event handler |
app.rs |
| View | fn draw(app: &App, frame: &mut Frame) |
ui.rs |
| Message | KeyEvent dispatched by mode in handle_events |
event.rs |
The InputMode enum acted as a state machine within the model: Normal, Adding, Editing, ConfirmDelete. Each mode defined which keys were active and what they meant. The event handler matched on the mode first, then on the key, then called the appropriate App method. The view read the model and projected it onto the terminal. One loop, three concerns, zero surprises.
The ratatui event loop in practice:
loop {
// View: project model onto terminal
terminal.draw(|frame| ui::draw(&app, frame, &mut table_state))?;
// Read: wait for user event
if event::poll(Duration::from_millis(16))? {
if let Event::Key(key) = event::read()? {
// Update: transform model based on message
event::handle_events(&mut app, key)?;
}
}
}Draw, poll, handle. The phases are sequential, never simultaneous. In Rust, this is not just a design choice: the borrow checker enforces it. You cannot hold &app (for rendering) and &mut app (for updating) at the same time. The TEA structure satisfies the borrow checker by design.
TEA in Scala: immutable models and pattern matching #
Scala does not have a dominant TUI framework like ratatui, but the TEA pattern fits Scala’s strengths perfectly: immutable case classes for the model, sealed types for messages, and exhaustive pattern matching for the update.
Consider a slightly richer example: a task manager model.
// -- Model --
case class Task(title: String, done: Boolean)
case class Model(
tasks: List[Task],
filter: Filter,
input: String,
mode: Mode
)
// -- Messages --
enum Msg:
case AddTask
case ToggleTask(index: Int)
case DeleteTask(index: Int)
case SetInput(text: String)
case CycleFilter
case EnterAddMode
case Cancel
enum Mode:
case Normal, Adding
enum Filter:
case All, Todo, DoneThe update function is a single match:
def update(model: Model, msg: Msg): Model = msg match
case Msg.AddTask =>
val task = Task(title = model.input, done = false)
model.copy(tasks = model.tasks :+ task, input = "", mode = Mode.Normal)
case Msg.ToggleTask(i) =>
val updated = model.tasks.updated(i, model.tasks(i).copy(done = !model.tasks(i).done))
model.copy(tasks = updated)
case Msg.DeleteTask(i) =>
model.copy(tasks = model.tasks.patch(i, Nil, 1))
case Msg.SetInput(text) =>
model.copy(input = text)
case Msg.CycleFilter =>
val next = model.filter match
case Filter.All => Filter.Todo
case Filter.Todo => Filter.Done
case Filter.Done => Filter.All
model.copy(filter = next)
case Msg.EnterAddMode =>
model.copy(mode = Mode.Adding, input = "")
case Msg.Cancel =>
model.copy(mode = Mode.Normal, input = "")Every branch returns a new Model via copy. The old model is untouched. Adding a new Msg variant causes the compiler to warn about the non-exhaustive match, same as Rust’s exhaustive match on enums. The type system acts as a state machine verifier in both languages.
The view function in Scala would be a pure function that transforms the model into a string, a UI tree, or whatever output format the application uses. The point is the same as in Rust: the view does not decide, it describes.
How TEA relates to MVC #
If you have worked with MVC (Model-View-Controller), the comparison is instructive.
| Aspect | MVC | TEA |
|---|---|---|
| State location | Distributed across Model objects | Single Model struct/case class |
| State mutation | Controller calls methods on Model | Update returns new Model |
| View update | Observer pattern (Model notifies View) | Re-render from scratch each cycle |
| Event handling | Controller receives events, routes to Model | Messages dispatched to Update function |
| Data flow | Bidirectional (View ↔ Controller ↔ Model) | Unidirectional (Model → View → Message → Update → Model) |
The fundamental difference is data flow direction. MVC allows bidirectional communication: the view can talk to the controller, the controller to the model, the model back to the view. When something goes wrong, the state of the system depends on the order in which notifications fired.
TEA is unidirectional. Data flows in one direction: Model → View → Message → Update → Model. There are no back-channels. If you want to know why the screen shows what it shows, you look at the Model. If you want to know how the Model got into its current state, you replay the Messages through Update. The entire history of the application is a sequence of messages applied to an initial model.
For Scala developers coming from frameworks like Play or Akka HTTP: TEA is not a replacement for your web framework. It is a pattern for organizing the stateful, interactive parts of your application. A REPL, a CLI wizard, a dashboard, a game. Anywhere you have a loop that reads events and produces output.
When TEA breaks down #
TEA is not a silver bullet. There are scenarios where the pattern creates friction instead of reducing it.
Side effects #
The pure TEA loop has no room for side effects: HTTP requests, file I/O, timers, random numbers. In Elm, this is solved with Commands: the Update function returns both a new Model and a list of effects to execute. The runtime handles the effects and feeds the results back as new Messages.
Outside of Elm, you have to solve this yourself. In Rust, you might spawn a tokio task and send its result back via an mpsc channel. In Scala, you might return an IO or Future alongside the new model. The pattern still works, but the “pure Update” ideal gets pragmatic compromises. One common approach:
// Rust: Update returns model + optional command
fn update(model: Model, msg: Msg) -> (Model, Option<Command>) { ... }// Scala: Update returns model + optional effect
def update(model: Model, msg: Msg): (Model, Option[IO[Msg]]) = ...The Command/IO is executed by the loop, and its result becomes a new Message. This preserves the unidirectional flow while allowing side effects.
High-frequency updates #
If your application processes thousands of events per second (a real-time data visualization, a high-frequency trading UI), creating a new Model on every event can be expensive. In Scala, case class copies are cheap for small models but add GC pressure at scale. In Rust, the cost depends on what is inside the struct: a Vec<Task> clone is not free.
The immutable “return a new model” approach assumes the cost of creating a new state is negligible. When it is not, you need mutable state with targeted updates, and TEA’s simplicity becomes an overhead.
Deep component trees #
In large applications with many independent components, having a single Model that contains everything can become unwieldy. The Update function grows into a giant match block. This is why some architectures introduce composable reducers that handle slices of state. The pattern scales, but it needs structural additions.
When the model is the database #
If your application’s state is fundamentally a database (think: a spreadsheet, a CRM), the “single Model in memory” assumption stops making sense. The state is too large to hold in a struct or case class, and the updates are too complex to express as pure functions. TEA works best when the model fits in memory and the update logic is self-contained.
The mental checklist #
Before reaching for TEA, ask:
- Is the program event-driven? If it reacts to user input, network events, or timer ticks, TEA fits naturally.
- Can the state fit in one struct? If yes, Model is straightforward. If the state is distributed across databases, caches, and external services, you need more machinery.
- Is the update logic self-contained? If each message can be handled without calling external services, Update stays pure. If every message needs an API call, you need an effect system on top.
- Is the rendering cheap enough to redo from scratch? If the view is a function that produces a lightweight description (terminal widget tree, string output), re-rendering is fine. If rendering is expensive (a 3D scene with no change detection), you need incremental updates.
If you answered yes to all four, TEA will likely simplify your codebase. If you answered no to one or two, you can still use TEA as a starting point and add escape hatches where needed.
What you gain #
The deepest value of TEA is not a technical one. It is a cognitive one.
When you look at a TEA application, you can answer three questions immediately:
- What is the state of the application? → Look at the Model (
structin Rust,case classin Scala). - What can happen? → Look at the Message type (
enumin both languages). - How does the state change? → Look at the Update function (
matchin both languages).
No tracing callbacks. No debugging observer chains. No “which middleware mutated the state before my handler ran.” The loop fits in your head. And in a codebase that will be maintained for years by people who did not write it, that is worth more than any performance optimization.
The Elm Architecture is not clever. It is obvious. And that is the highest compliment a pattern can receive.