State Management

Redux-style Store with dispatch, listeners, middleware, and saga scheduling.

5 min read 1021 words

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 aThreadPoolExecutor when reducers return ReducerResult

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.

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.