Commands are a lightweight alternative to sagas for one-shot side effects. ACmd is a plain function that runs on the thread pool and optionally dispatches an action — no generators, no yield, no multi-step coordination.
When to use Commands vs. Sagas
| Use Commands when... | Use Sagas when... |
|---|---|
| One function call, one result | Multi-step workflows |
| No need to read state mid-effect | NeedSelectto read state between steps |
| No retry or backoff logic | NeedRetrywith exponential backoff |
| Fire-and-forget or single dispatch | NeedForkfor concurrent child tasks |
Cmd
ACmd wraps a function () -> Action | None. The store runs it on the thread pool and dispatches the returned action (if any).
from milo import Cmd, ReducerResult, Action
def fetch_status():
resp = urllib.request.urlopen("https://example.com")
return Action("STATUS_OK", payload=resp.status)
def reducer(state, action):
if action.type == "CHECK":
return ReducerResult({**state, "checking": True}, cmds=(Cmd(fetch_status),))
if action.type == "STATUS_OK":
return {**state, "checking": False, "status": action.payload}
return state
If the function returns None, nothing is dispatched. If it raises an exception, a @@CMD_ERROR action is dispatched with {"error": "message", "type": "ExceptionTypeName"}.
Batch
Run multiple commands concurrently with no ordering guarantees:
from milo import Batch, Cmd, ReducerResult
def reducer(state, action):
if action.type == "REFRESH_ALL":
return ReducerResult(
state,
cmds=(Batch((Cmd(fetch_users), Cmd(fetch_posts), Cmd(fetch_stats))),),
)
return state
All three commands execute on the thread pool in parallel. Each dispatches its result independently.
Sequence
Run commands serially — each result is dispatched before the next command starts:
from milo import Sequence, Cmd, ReducerResult
def reducer(state, action):
if action.type == "DEPLOY":
return ReducerResult(
state,
cmds=(Sequence((Cmd(validate), Cmd(build), Cmd(publish))),),
)
return state
validate runs first. Once it finishes and its result is dispatched, build starts. Then publish.
Nesting
Batch and Sequencecompose recursively:
# Validate first, then build and test in parallel, then publish
Sequence((
Cmd(validate),
Batch((Cmd(build), Cmd(test))),
Cmd(publish),
))
compact_cmds
Helper to clean up command tuples by strippingNoneentries:
from milo import compact_cmds, Cmd
cmds = compact_cmds(
Cmd(fetch_users) if needs_users else None,
Cmd(fetch_posts) if needs_posts else None,
)
# Returns () if both are None, (Cmd(...),) if one, or both
return ReducerResult(state, cmds=cmds)
TickCmd
Schedule a single@@TICK action after an interval. Return another TickCmd from your @@TICKhandler to keep the loop going — omit it to stop:
from milo import TickCmd, ReducerResult
def reducer(state, action):
if action.type == "START_POLLING":
return ReducerResult(
{**state, "polling": True},
cmds=(TickCmd(2.0),), # First tick in 2 seconds
)
if action.type == "@@TICK" and state["polling"]:
# Do work, then schedule next tick
return ReducerResult(
{**state, "poll_count": state["poll_count"] + 1},
cmds=(TickCmd(2.0),), # Keep ticking
)
if action.type == "STOP_POLLING":
return {**state, "polling": False} # No TickCmd = stop
return state
Tip
TickCmd gives you per-component, dynamic tick control — different rates for different features, start and stop based on state. The App(tick_rate=...)parameter is still available as a simpler always-on alternative.
ViewState
Declare terminal features from your reducer instead of managing them imperatively. The renderer diffs previous vs. currentViewStateand applies only the changes:
from milo import ViewState, ReducerResult
def reducer(state, action):
if action.type == "EDIT_MODE":
return ReducerResult(
{**state, "mode": "edit"},
view=ViewState(cursor_visible=True, window_title="Editing"),
)
if action.type == "VIEW_MODE":
return ReducerResult(
{**state, "mode": "view"},
view=ViewState(cursor_visible=False, window_title="Viewing"),
)
return state
| Field | Type | Description |
|---|---|---|
alt_screen |
bool | None |
Enter/leave alternate screen buffer |
cursor_visible |
bool | None |
Show/hide the terminal cursor |
window_title |
str | None |
Set the terminal window title |
mouse_mode |
bool | None |
Enable/disable mouse event reporting |
Fields set toNone(the default) are left unchanged — only explicitly set fields trigger terminal escape sequences.
ViewState works on both ReducerResult and Quit:
# Show cursor before exiting so the terminal is clean
return Quit(state, view=ViewState(cursor_visible=True))