The Elm Architecture

The Elm Architecture

Also known as TEA or MVU (Model–View–Update). A pattern for building reactive UIs (and reactive programs in general) where state mutation is centralised in one function, the view is a pure function of state, and side effects are explicit data values.

Originated in Elm around 2014. Now the dominant shape of reactive UI architecture, copied (often unknowingly) into Redux, Bubbletea, tui-realm, Compose, .NET MVU, lazydap, and many others.

The shape

Three things define a TEA app:

   Model     —  the entire state of your app, as plain data
   Msg       —  a description of "something happened"
   Update    —  a pure function: (Model, Msg) -> (Model, Cmd)
   View      —  a pure function: Model -> UI
   Cmd       —  a description of a side effect to perform (optional 4th)
   Sub       —  subscription to external events (optional 5th)

The runtime loop:

   ┌──────────────────────────────────────┐
   │                                      │
   │      ┌────────────────────────┐      │
   │      │       Model            │      │
   │      └────────────┬───────────┘      │
   │                   │                   │
   │                   ▼                   │
   │      ┌────────────────────────┐      │
   │      │   View(Model) → UI     │      │
   │      └────────────┬───────────┘      │
   │                   │                   │
   │            user interacts             │
   │                   │                   │
   │                   ▼                   │
   │      ┌────────────────────────┐      │
   │      │         Msg            │      │
   │      └────────────┬───────────┘      │
   │                   │                   │
   │                   ▼                   │
   │      ┌────────────────────────┐      │
   │      │  Update(Model, Msg)    │      │
   │      │   → (Model, Cmd)       │      │
   │      └────────────┬───────────┘      │
   │                   │                   │
   │              new Model                │
   │                   │                   │
   └───────────────────┘

External events (timers, network responses, user input, IPC messages) all become Msgs and feed into Update. Update produces a new Model and possibly a Cmd. View renders the new Model. Loop.

Why this shape works

Three properties fall out:

  1. All state mutation in one place. Update is the only function that produces a new Model. A future-you reading the codebase looks at Update to understand "how does this app's state change?" — and it's all there. No mutations scattered across event handlers.

  2. View is pure. Given the same Model, View always produces the same UI. No race conditions where the UI doesn't match state. No "why is the button stale?" debugging. The view IS the state.

  3. Side effects are explicit. Network calls, timers, file writes, IPC sends — all return as Cmd values from Update. The runtime executes them and feeds the results back as Msgs. User code stays pure. Tests don't have to mock anything.

The discipline is constraining. Once you have it, programs grow without growing complexity. Add a new feature = add a Msg variant + handle it in Update + render in View. No sprawling event handlers, no tangled callbacks.

A complete example

A counter that fetches a random number:

type alias Model = { count : Int, randomNumber : Maybe Int }

type Msg
    = Increment
    | Decrement
    | RequestRandom
    | GotRandom Int

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        Increment ->
            ({ model | count = model.count + 1 }, Cmd.none)

        Decrement ->
            ({ model | count = model.count - 1 }, Cmd.none)

        RequestRandom ->
            (model, Random.generate GotRandom (Random.int 1 100))

        GotRandom n ->
            ({ model | randomNumber = Just n }, Cmd.none)

view : Model -> Html Msg
view model =
    div []
        [ button [ onClick Decrement ] [ text "-" ]
        , text (String.fromInt model.count)
        , button [ onClick Increment ] [ text "+" ]
        , button [ onClick RequestRandom ] [ text "random" ]
        , text (case model.randomNumber of
            Just n -> " random: " ++ String.fromInt n
            Nothing -> ""
        )
        ]

Notice:

If you want to test this, you call update with a model and a message and assert the new model. No mocking. No DOM. No setUp/tearDown.

In Rust (for Lazydap)

The same shape, hand-rolled:

pub struct AppState { /* model */ }

pub enum Msg {
    Key(KeyEvent),
    DapEventEvent,
    Tick,
    LoadSourceCompleted(PathBuf, Result<String>),
}

pub enum Cmd {
    Quit,
    LoadSource(PathBuf),
    SendIpc(Request),
    None,
}

pub fn update(state: AppState, msg: Msg) -> (AppState, Cmd) {
    match msg {
        Msg::Key(KeyEvent { code: KeyCode::F(5), .. }) => {
            (state, Cmd::SendIpcContinue { .. })
        }
        Msg::DapEventStopped { .. } => {
            (state.with_paused(...), Cmd::None)
        }
        // every event one branch
        _ => (state, Cmd::None),
    }
}

pub fn view(frame: &mut Frame, state: &AppState) {
    // pure render from state
}

Same pattern, lower magic. ~50 lines of boilerplate around update and view and you've reimplemented Elm's runtime in Rust.

This is what Lazydap does in crates/tui/src/update.rs. Same in Mxr's TUI layer (with slight variations).

What this isn't

Trade-offs

Wins:

Costs:

For non-trivial apps (~hundreds of LoC and up), the wins decisively outweigh the costs. For one-page demos, the boilerplate is real.

See also