Milo uses a centralizedStorethat holds the entire application state. State changes happen through pure reducer functions, making every transition explicit and testable.
Store
TheStoreis the single source of truth for your application's state.
from milo import Store, Action
def reducer(state, action):
if state is None:
return {"count": 0}
if action.type == "INCREMENT":
return {**state, "count": state["count"] + 1}
return state
store = Store(reducer, initial_state=None)
store.dispatch(Action("INCREMENT"))
print(store.get_state()) # {"count": 1}
The store:
- Serializes dispatches through a threading lock
- Notifies listeners after each state change
- Schedules sagas on a
ThreadPoolExecutorwhen reducers returnReducerResult
Note
The store's dispatch lock ensures actions are processed one at a time, even under free-threading. This gives you sequential consistency without sacrificing saga parallelism.
Reducers
A reducer is a pure function:(state, action) -> state. It receives the current state and an action, and returns the next state. Reducers must not mutate state — always return a new dict or dataclass.
def todo_reducer(state, action):
if state is None:
return {"todos": [], "filter": "all"}
if action.type == "ADD_TODO":
return {**state, "todos": [*state["todos"], action.payload]}
if action.type == "SET_FILTER":
return {**state, "filter": action.payload}
return state
Warning
Never mutate state directly.state["count"] += 1breaks the Elm Architecture contract — listeners won't fire correctly, and session replay will produce different results.
Actions
Actions are frozen dataclasses with atype string and an optional payload.
from milo import Action
action = Action("ADD_TODO", payload="Buy milk")
action = Action("@@KEY", payload=key) # Built-in key action
Milo dispatches several built-in actions automatically: @@INIT, @@KEY, @@TICK, @@RESIZE, @@QUIT, @@NAVIGATE, @@HOT_RELOAD, @@EFFECT_RESULT, @@SAGA_ERROR, and @@CMD_ERROR.
Reducer combinators
Milo ships decorator combinators that handle the most common reducer patterns — quit keys, cursor navigation, and confirm-to-exit — so you don't have to rewrite them in every app.
quit_on
Wraps a reducer to exit on specific keys:
from milo import quit_on, SpecialKey
@quit_on("q", SpecialKey.ESCAPE)
def reducer(state, action):
if state is None:
return State()
return state
The inner reducer runs first, so any app-specific logic for the quit key is preserved in the final state.
with_cursor
Adds UP/DOWN arrow navigation over a sequence field:
from milo import with_cursor
@with_cursor("items")
def reducer(state, action):
if state is None:
return State(items=("a", "b", "c"), cursor=0)
return state
Options:
cursor_field— name of the cursor attribute (default:"cursor")wrap— wrap around at list boundaries (default:False)
with_confirm
ReturnsQuit(state)when the user presses a confirm key:
from milo import with_confirm
@with_confirm() # defaults to Enter
def reducer(state, action):
return state
Pass a character to use a different key: @with_confirm(" ")for spacebar.
Composition
Stack all three for a complete interactive list:
@with_confirm()
@with_cursor("items", wrap=True)
@quit_on("q", SpecialKey.ESCAPE)
def reducer(state, action):
if state is None:
return ListState()
return state
Decorators compose outside-in. In the example above, with_confirm checks first, then with_cursor handles arrows, then quit_onchecks quit keys, and finally your inner reducer runs for everything else.
Tip
Skip the combinators when your quit or cursor logic is non-standard — for example, if pressingq needs to set a cancelled=Trueflag, or if your cursor tracks a filtered view that changes dynamically.
Combining reducers
For larger apps, split state into slices withcombine_reducers:
from milo import combine_reducers
reducer = combine_reducers(counter=counter_reducer, ui=ui_reducer)
# State shape: {"counter": 0, "ui": {"theme": "dark"}}
def counter_reducer(state, action):
if state is None:
return 0
if action.type == "INCREMENT":
return state + 1
return state
def ui_reducer(state, action):
if state is None:
return {"theme": "dark"}
return state
Each sub-reducer manages its own slice of state.combine_reducersroutes actions to all sub-reducers and assembles the combined state.
Middleware
Middleware wraps the dispatch function to intercept, transform, or log actions.
def logging_middleware(dispatch, get_state):
def wrapper(action):
print(f"[{action.type}] {action.payload}")
return dispatch(action)
return wrapper
store = Store(reducer, initial_state=None, middleware=[logging_middleware])
Middleware signature: (dispatch_fn, get_state_fn) -> dispatch_fn. Middleware composes left-to-right — the first middleware in the list wraps the outermost dispatch.
Common middleware patterns
Timing middleware — measure reducer performance:
import time
def timing_middleware(dispatch, get_state):
def wrapper(action):
start = time.perf_counter()
result = dispatch(action)
elapsed = time.perf_counter() - start
if elapsed > 0.016: # Slower than 60fps
print(f"Slow dispatch: {action.type} took {elapsed:.3f}s")
return result
return wrapper
Filter middleware — ignore actions conditionally:
def ignore_ticks(dispatch, get_state):
def wrapper(action):
if action.type == "@@TICK" and get_state().get("paused"):
return # Swallow tick while paused
return dispatch(action)
return wrapper
Listeners
Subscribe to state changes:
def on_change(state):
print(f"State changed: {state}")
store.subscribe(on_change)
Tip
Listeners fire after every dispatch. For expensive operations (API calls, file writes), trigger them from sagas instead — sagas run on the thread pool and won't block rendering.
Side effects from reducers
ReducerResultis the bridge between pure reducers and side effects. It carries the new state plus optional sagas, commands, and view state:
from milo import ReducerResult, Cmd, TickCmd, ViewState
def reducer(state, action):
if action.type == "FETCH":
return ReducerResult(
{**state, "loading": True},
sagas=(fetch_saga,), # Generator-based effects
cmds=(Cmd(quick_check),), # Lightweight thunks
view=ViewState(cursor_visible=False), # Terminal state
)
return state
| Field | Type | Description |
|---|---|---|
state |
Any | The new state |
sagas |
tuple[Callable, ...] |
Generator-based effects (multi-step) |
cmds |
tuple[Cmd | Batch | Sequence | TickCmd, ...] |
Lightweight command thunks |
view |
ViewState | None |
Declarative terminal state changes |
For details on commands, see Commands. For sagas, see Sagas.
Message filter
TheApp accepts an optional filter function that intercepts actions before they reach the store. Return Noneto drop the action, or return a (possibly different) action to transform it:
def block_quit_while_saving(state, action):
if action.type == "@@QUIT" and state.get("saving"):
return None # Swallow quit during save
return action
app = App(
template="app.kida",
reducer=reducer,
initial_state=None,
filter=block_quit_while_saving,
)
Filters are simpler than middleware for the common case of conditionally dropping or rewriting actions. Use middleware when you need to wrap the full dispatch chain.