Side Effects via Cmd

Side Effects via Cmd

How The Elm Architecture handles I/O — network calls, timers, file writes, IPC sends, anything observable — without breaking purity. The trick: instead of performing side effects from user code, the user code returns descriptions of side effects (called Cmd values), and the runtime performs them.

This is the bit that surprises people when they first see it. "I wanted to fetch from a URL — why am I returning a value that says 'please fetch'?" The answer: because it preserves the discipline that makes everything else easy.

The pattern

Without Cmd:

function update(model, msg) {
    if (msg === "fetchUser") {
        fetch("/api/user")          // <-- direct side effect
            .then(r => r.json())
            .then(user => setModel({...model, user}));
        return model;
    }
    // ...
}

The fetch happens inside update. The function is impure. Tests have to mock fetch. Time-travel-debugging breaks (re-running update re-fires the fetch).

With Cmd:

update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
    case msg of
        FetchUser ->
            ( model
            , Http.get
                { url = "/api/user"
                , expect = Http.expectJson GotUser userDecoder
                }
            )
        GotUser (Ok user) ->
            ({ model | user = Just user }, Cmd.none)
        GotUser (Err _) ->
            ({ model | error = Just "fetch failed" }, Cmd.none)

update describes "I want a Cmd that does an HTTP GET to /api/user; whatever happens, dispatch a GotUser Msg with the result." It returns. The runtime sees the Cmd, performs the fetch, gets a result, dispatches GotUser back into update.

The user code (update) is pure. The runtime handles the impure parts.

Why this matters

Three properties survive:

1. Update is testable

let (new_state, cmd) = update(state, Msg::FetchUser);
assert_eq!(cmd, Cmd::FetchUrl("/api/user", Msg::GotUser));

Test asserts that a Cmd was returned. Doesn't actually fetch. No HTTP mocks needed. The Cmd is a value; values are easy to assert.

2. Time-travel debugging works

You can re-run update over a recorded sequence of Msgs. The Cmds are recorded too, but they're not re-performed during replay (the runtime knows we're in replay mode). You can step backward, see "the model was X here, then we received Msg Y, and update returned new model Z + Cmd C." Possible because Cmd is data.

3. Side effects can be intercepted

The runtime layer can transform Cmds: log them, batch them, simulate failures, replace HTTP with mocked responses for testing. The user code doesn't change. The runtime is the seam.

Common Cmd types

In real apps, Cmd is a sum type covering the side effects you actually need:

pub enum Cmd {
    None,                                    // do nothing
    Quit,                                    // exit the app
    SendIpc(Request),                        // send an IPC request to the daemon
    LoadSource(PathBuf),                     // read a file async
    StartTimer(Duration, Msg),               // schedule a Msg to fire later
    FocusElement(ElementId),                 // imperatively focus a UI widget
    Batch(Vec<Cmd>),                         // perform multiple Cmds
}

The runtime has a handler for each variant:

async fn perform(cmd: Cmd, msg_tx: mpsc::Sender<Msg>) {
    match cmd {
        Cmd::None => {}
        Cmd::Quit => exit_loop(),
        Cmd::SendIpc(req) => {
            let response = ipc_client.send(req).await;
            msg_tx.sendIpcResponse(response).await;
        }
        Cmd::LoadSource(path) => {
            let result = fs::read_to_string(path).await;
            msg_tx.sendLoadSourceCompleted(path, result).await;
        }
        Cmd::StartTimer(dur, msg) => {
            tokio::spawn(async move {
                tokio::time::sleep(dur).await;
                msg_tx.send(msg).await;
            });
        }
        Cmd::Batch(cmds) => {
            for c in cmds {
                perform(c, msg_tx.clone()).await;
            }
        }
        // ...
    }
}

Effects happen here; everything else stays pure.

Subscriptions (Sub) — the other half

Cmd is for side effects you initiate. Sub is for external events you want to observe — keyboard input, timer ticks, websocket messages, IPC events arriving from outside. Subscriptions translate external events into Msgs that flow into update.

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.batch
        [ Time.every 1000 Tick
        , WebSocket.listen "wss://example.com" GotMessage
        , Browser.Events.onKeyDown keyDecoder
        ]

The runtime sets up the listeners, dispatches Msgs as events arrive. User code stays pure.

In Rust + Tokio + ratatui (lazydap's pattern), subscriptions are usually just tokio::select! over input channels:

loop {
    let msg = tokio::select! {
        Some(m) = input_rx.recv() => m,
        Some(m) = ipc_rx.recv() => m,
        _ = tick.tick() => Msg::Tick,
    };
    let (new_state, cmd) = update(state, msg);
    state = new_state;
    perform(cmd).await;
}

The select! IS the subscription mechanism — it observes external sources and produces Msgs.

Why "value, not action" is the key insight

Most languages model side effects as actions: you call fetch(url) and stuff happens. Pure functional languages (Elm, Haskell) model them as values: you construct a value that describes an effect; the runtime interprets it.

Once effects are values, all the things you can do with values become available:

These all become impossible (or radically harder) when effects are actions executed in-place.

The trade is: you write more types. Cmd<Msg> is more code than void. The trade pays off as the codebase grows.

How Lazydap uses this

Per crates/tui/src/:

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

pub fn update(state: AppState, msg: Msg) -> (AppState, Cmd) {
    match msg {
        Msg::Key(key) if key.code == KeyCode::F(5) => {
            (state, Cmd::SendIpcContinue { ... })
        }
        // ...
    }
}

update returns a Cmd describing the IPC send. The main loop calls perform(cmd) which actually pushes the request into the IPC client. update itself is pure; tests can assert what Cmds it produced without involving any I/O.

Common pitfalls

See also