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:
- Pass them as function arguments
- Return them from functions
- Store them in lists
- Inspect them in tests
- Transform them in middleware
- Replay them later
- Cancel them before execution
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
- Performing side effects in
update. The first time you "just call this function for convenience" the discipline cracks. Resist; route through Cmd. - Cmd::Batch with order dependencies.
Cmd::Batch([A, B])doesn't guarantee A executes before B. If you need ordering, model it as A produces a Msg whose handler returns B. - Mutating model from inside Cmd handlers. Cmd handlers should produce Msgs; not mutate state. The Msg flowing through
updateis what produces new state. - Forgetting Cmd::None. Some Msgs only update state without triggering effects.
Cmd::Noneis the no-op.
See also
- The Elm Architecture — the pattern Cmd is part of
- Pure Functions and Immutable State — the constraint Cmd preserves
- Reducers — Cmd's equivalent in non-Elm ecosystems (often called "thunks" or "effects")
- Lazydap — concrete usage