Nested state machines
In the previous chapter we saw the top-level Model behave as a lifecycle state machine: each phase is its own variant, each transition is explicit, and the Outcome type is how a stage tells the parent what to do next. That pattern isn't reserved for the top level — it runs all the way down. Every screen inside Active, and every workflow inside those screens, is its own small state machine, composed the same way.
This chapter zooms in on that nesting: the Outcome protocol itself, a worked example of a small state machine, and how transitions from deep inside the hierarchy can bubble all the way back up to the lifecycle.
The Outcome pattern
Three types do all the work. First, the result of any sub-state-machine step:
#![allow(unused)] fn main() { /// The result of a state machine's `update()` method. /// /// Pairs a [`Status`] — continue with an updated state, or complete with /// a transition value — with a `Command` describing the effects produced /// by the update. The parent destructures it, reacts to the status, and /// runs the command. /// /// Construct with [`Outcome::continuing`] or [`Outcome::complete`]. Use /// [`Outcome::map_event`] to lift the inner command's event type before /// returning it from the parent's own update. pub(crate) struct Outcome<S, T, Event> { pub status: Status<S, T>, pub command: Command<Effect, Event>, } impl<S, T, Event> Outcome<S, T, Event> { /// Constructs an outcome that keeps the state machine running with the /// given updated state and command. pub fn continuing(state: S, command: Command<Effect, Event>) -> Self { Outcome { status: Status::Continue(state), command, } } /// Constructs an outcome that exits the state machine with the given /// transition value and command. pub fn complete(value: T, command: Command<Effect, Event>) -> Self { Outcome { status: Status::Complete(value), command, } } /// Destructures into the status and the command to run. pub fn into_parts(self) -> (Status<S, T>, Command<Effect, Event>) { (self.status, self.command) } /// Lifts the event type of the inner command. /// /// Typically used by a parent to wrap the child's event variant before /// returning the command from its own update. pub fn map_event<NewEvent>( self, f: impl Fn(Event) -> NewEvent + Send + Sync + 'static, ) -> Outcome<S, T, NewEvent> where Event: Send + Unpin + 'static, NewEvent: Send + Unpin + 'static, { Outcome { status: self.status, command: self.command.map_event(f), } } } }
An Outcome is a Status — either Continue(State) (the machine keeps running with the updated state) or Complete(Transition) (the machine has exited, here's the value telling the parent what happens next) — paired with a Command describing any effects the update produced.
#![allow(unused)] fn main() { /// Whether a state-machine step kept running or exited with a transition. /// /// Returned inside an [`Outcome`], typically constructed indirectly via /// [`Outcome::continuing`] or [`Outcome::complete`]. #[derive(Debug)] pub(crate) enum Status<S, T> { /// The state machine is still running; this is the updated state to /// assign back into the parent. Continue(S), /// The state machine has exited; this is the transition value carrying /// whatever the parent needs to move to the next phase. Complete(T), } }
And the counterpart for starting a state machine up:
#![allow(unused)] fn main() { /// The result of a state machine's `start()` method. /// /// A `start()` both constructs the initial state and returns the commands /// that must run alongside it — HTTP fetches, permission checks, a render. /// `Started` bundles those so the caller can destructure them in one step /// with [`Started::into_parts`]. /// /// Use [`Started::map_event`] to lift the inner command's event type into /// a wider parent event before returning it from the parent's own logic. pub(crate) struct Started<S, Event> { pub state: S, pub command: Command<Effect, Event>, } impl<S, Event> Started<S, Event> { /// Creates a new `Started` from an initial state and its accompanying /// command. pub fn new(state: S, command: Command<Effect, Event>) -> Self { Started { state, command } } /// Destructures into the initial state and the command to run. pub fn into_parts(self) -> (S, Command<Effect, Event>) { (self.state, self.command) } /// Lifts the event type of the inner command. /// /// Typically used by a parent to wrap the child's event variant, for /// example `child::start().map_event(ParentEvent::Child)`. pub fn map_event<NewEvent>( self, f: impl Fn(Event) -> NewEvent + Send + Sync + 'static, ) -> Started<S, NewEvent> where Event: Send + Unpin + 'static, NewEvent: Send + Unpin + 'static, { Started { state: self.state, command: self.command.map_event(f), } } } }
A start() returns a Started<Self, Event> — the initial state bundled with the commands that kick off the work. The map_event methods on both types lift a child's event variant into its parent's wider event type, so each layer only needs to know about its direct children, not the whole tree beneath them.
That's the whole protocol. Now let's see it in use.
A worked example: local weather
The home screen shows two things: local weather and weather for saved favourites. The local-weather half is a small state machine in its own right:
#![allow(unused)] fn main() { /// The state of the local-weather workflow. /// /// The machine progresses through these states as events resolve: /// `CheckingPermission` → `FetchingLocation` → `FetchingWeather` → `Fetched`. /// Either permission or location can short-circuit to `LocationDisabled`, /// and a failed weather fetch lands in `Failed`. All non-terminal states /// accept `Retry` to restart from the beginning. #[derive(Debug, Clone, Default)] pub enum LocalWeather { /// Initial state: asking the shell whether location services are on. #[default] CheckingPermission, /// Location services are off or the user denied them; the UI shows a /// "location disabled" panel with a retry button. LocationDisabled, /// Location services are on; waiting for the shell to return the /// current coordinates. FetchingLocation, /// We have coordinates; waiting for the weather API response. FetchingWeather(Location), /// We have current weather for the user's location — terminal happy /// path until a `Retry`. Fetched(Location, Box<CurrentWeatherResponse>), /// Weather fetch failed for reasons other than unauthorized (network, /// malformed response). The UI shows an error with a retry button. Failed(Location), } }
The states map directly to what the UI shows: we're checking permissions, location is disabled, we're fetching coordinates, we're fetching weather, we have weather, or the fetch failed. Each state is moved forward by an event:
#![allow(unused)] fn main() { /// Events emitted as location permission, location fetch, and weather fetch /// resolve — plus an explicit retry from the UI. #[derive(Clone, Debug, PartialEq)] pub enum LocalWeatherEvent { /// The shell reported whether location services are enabled. LocationEnabled(bool), /// The shell returned the current coordinates, or `None` if it couldn't /// determine them. LocationFetched(Option<Location>), /// The weather API responded with current conditions, or an error. WeatherFetched(Box<Result<CurrentWeatherResponse, WeatherError>>), /// The user tapped "retry" after a disabled or failed state. Retry, } }
Starting the state machine kicks off the first effect — asking the shell whether location services are enabled:
#![allow(unused)] fn main() { /// Starts the state machine in `CheckingPermission` and asks the shell /// whether location services are enabled. pub(crate) fn start() -> Started<Self, LocalWeatherEvent> { tracing::debug!("checking location permissions"); let cmd = crate::effects::location::command::is_location_enabled() .then_send(LocalWeatherEvent::LocationEnabled); Started::new(Self::CheckingPermission, cmd) } }
This is the Started pattern we first saw in chapter 3, now at a lower level. The update function walks through each event and returns an Outcome:
#![allow(unused)] fn main() { /// Advances the state machine on an event, using `api_key` to authorise /// the weather API call when needed. /// /// - `LocationEnabled(true)` → fetch location → `FetchingLocation`. /// - `LocationEnabled(false)` or `LocationFetched(None)` → /// `LocationDisabled` with a render. /// - `LocationFetched(Some(_))` → request weather → `FetchingWeather`. /// - `WeatherFetched(Ok)` → `Fetched` with the response. /// - `WeatherFetched(Err(Unauthorized))` → `Complete` with /// [`LocalWeatherTransition::Unauthorized`]. /// - `WeatherFetched(Err(_))` → `Failed` (network or parse errors). /// - `Retry` → restart via [`Self::start`]. pub(crate) fn update( self, event: LocalWeatherEvent, api_key: &ApiKey, ) -> Outcome<Self, LocalWeatherTransition, LocalWeatherEvent> { match event { LocalWeatherEvent::Retry => { let Started { state, command } = Self::start(); Outcome::continuing(state, command) } LocalWeatherEvent::LocationEnabled(enabled) => { tracing::debug!("location enabled: {enabled}"); if enabled { tracing::debug!("fetching current location"); let cmd = get_location().then_send(LocalWeatherEvent::LocationFetched); Outcome::continuing(Self::FetchingLocation, cmd) } else { Outcome::continuing(Self::LocationDisabled, render()) } } LocalWeatherEvent::LocationFetched(location) => { tracing::debug!("received location: {location:?}"); match location { Some(loc) => { let cmd = weather_api::fetch(loc, api_key.clone()).then_send(|result| { LocalWeatherEvent::WeatherFetched(Box::new(result)) }); Outcome::continuing(Self::FetchingWeather(loc), cmd) } None => Outcome::continuing(Self::LocationDisabled, render()), } } LocalWeatherEvent::WeatherFetched(result) => { let Self::FetchingWeather(location) = self else { return Outcome::continuing(self, Command::done()); }; match *result { Ok(weather_data) => { tracing::debug!("received weather data for {}", weather_data.name); Outcome::continuing( Self::Fetched(location, Box::new(weather_data)), render(), ) } Err(WeatherError::Unauthorized) => { tracing::warn!("weather API returned unauthorized"); Outcome::complete(LocalWeatherTransition::Unauthorized, render()) } Err(ref e) => { tracing::warn!("fetching weather failed: {e:?}"); Outcome::continuing(Self::Failed(location), render()) } } } } } }
Most branches return Outcome::continuing — the machine keeps running with the new state, and a new command is attached (fetch location, fetch weather, render the disabled panel). Only one path completes the machine: a 401 from the weather API, which returns Outcome::complete with the single transition this machine exposes:
#![allow(unused)] fn main() { /// The exits from the local-weather state machine. /// /// Only one today: the weather API rejected our key, so the parent should /// bubble up to a reset/onboarding flow. #[derive(Debug)] pub(crate) enum LocalWeatherTransition { /// The weather API returned 401; the API key needs re-entry. Unauthorized, } }
That Unauthorized transition is how LocalWeather tells its parent: "I'm done; the API key is no longer valid."
Nesting: HomeScreen composes two sub-workflows
HomeScreen contains LocalWeather alongside a second workflow that fetches weather for each saved favourite. The home-screen events reflect that:
#![allow(unused)] fn main() { /// Events for the home screen: user navigation plus sub-workflow events. /// /// The `Local` and `FavoritesWeather` variants are `#[serde(skip)]` / /// `#[facet(skip)]` because the sub-workflows' events are internal to the /// core — the shell only sends `GoToFavorites`. #[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)] #[repr(C)] pub enum HomeEvent { /// The user tapped the favourites button in the home toolbar. GoToFavorites, /// Internal event routed to the local-weather state machine /// ([`LocalWeather`]). #[serde(skip)] #[facet(skip)] Local(#[facet(opaque)] LocalWeatherEvent), /// Internal event routed to the favourites weather workflow. #[serde(skip)] #[facet(skip)] FavoritesWeather(#[facet(opaque)] FavoriteWeatherEvent), } }
HomeEvent::Local(...) and HomeEvent::FavoritesWeather(...) are how the parent carries events for each sub-workflow. Shell-sent events go through HomeEvent::GoToFavorites; the others are internal routing, which is why they're marked #[serde(skip)] and #[facet(skip)].
Starting the home screen starts both sub-workflows in parallel:
#![allow(unused)] fn main() { /// Starts the home screen by kicking off both sub-workflows in parallel: /// the local-weather permission check and the per-favourite weather /// fetches. The returned command combines both. pub(crate) fn start(favorites: &Favorites, api_key: &ApiKey) -> Started<Self, HomeEvent> { tracing::debug!("starting home screen"); let (current_weather, local_cmd) = LocalWeather::start() .map_event(HomeEvent::Local) .into_parts(); let (favorites_weather, fav_cmd) = self::favorites::start(favorites, api_key) .map_event(HomeEvent::FavoritesWeather) .into_parts(); let screen = Self { current_weather, favorites_weather, }; Started::new(screen, local_cmd.and(fav_cmd)) } }
Each child start() returns a Started<ChildState, ChildEvent>; map_event lifts the child's event type (LocalWeatherEvent, FavoriteWeatherEvent) into the parent's HomeEvent. The two commands are combined with Command::and and returned as a single Started<HomeScreen, HomeEvent>.
Updating is symmetric — unwrap the parent event, delegate to the child's update, match on the resulting status:
#![allow(unused)] fn main() { /// Advances the home screen on an event, using `api_key` to authorise /// any weather API calls. /// /// - `GoToFavorites` → `Complete` with [`HomeTransition::GoToFavorites`]. /// - `Local(event)` → delegated to [`LocalWeather::update`]. An /// `Unauthorized` transition from the sub-machine is lifted to /// [`HomeTransition::ApiKeyRejected`]. /// - `FavoritesWeather(event)` → delegated to the favourites workflow; /// same lifting from its `Unauthorized` transition. pub(crate) fn update( self, event: HomeEvent, api_key: &ApiKey, ) -> Outcome<Self, HomeTransition, HomeEvent> { match event { HomeEvent::GoToFavorites => Outcome::complete( HomeTransition::GoToFavorites(self.favorites_weather.into()), render(), ), HomeEvent::Local(local_event) => { let Self { current_weather, favorites_weather, } = self; let (status, cmd) = current_weather .update(local_event, api_key) .map_event(HomeEvent::Local) .into_parts(); match status { Status::Continue(current_weather) => Outcome::continuing( Self { current_weather, favorites_weather, }, cmd, ), Status::Complete(LocalWeatherTransition::Unauthorized) => Outcome::complete( HomeTransition::ApiKeyRejected(favorites_weather.into()), cmd, ), } } HomeEvent::FavoritesWeather(fav_event) => { let Self { current_weather, favorites_weather, } = self; let (status, cmd) = self::favorites::update(favorites_weather, fav_event) .map_event(HomeEvent::FavoritesWeather) .into_parts(); match status { Status::Continue(favorites_weather) => Outcome::continuing( Self { current_weather, favorites_weather, }, cmd, ), Status::Complete(FavoriteWeatherTransition::Unauthorized(favorites)) => { Outcome::complete(HomeTransition::ApiKeyRejected(favorites), cmd) } } } } } }
For each sub-workflow branch, a Continue re-packages the updated state back into a fresh HomeScreen, while a Complete gets mapped to a HomeTransition. That's where the 401 path becomes interesting.
Propagating transitions upward
A LocalWeatherTransition::Unauthorized doesn't escape HomeScreen as-is. It's lifted to a HomeTransition:
#![allow(unused)] fn main() { /// The exits from the home screen. #[derive(Debug)] pub(crate) enum HomeTransition { /// The user navigated to the favourites screen; the current favourites /// list is carried over so the next screen has it. GoToFavorites(Favorites), /// The weather API rejected our key from one of the nested workflows; /// the parent should route back through onboarding. ApiKeyRejected(Favorites), } }
HomeTransition::ApiKeyRejected(Favorites) carries the current favourites list along, because whatever comes next still needs them. The active-model update does the same lift: it maps HomeTransition::ApiKeyRejected to ActiveTransition::Unauthorized, still carrying the favourites. The top-level update_active then sees Complete(Unauthorized) — exactly the handler we wrote in chapter 3 — and swaps Model::Active for Model::Onboard.
That's the full round trip: a 401 from the weather API, three levels below the top of the model tree, propagates up through three transition types until it becomes a lifecycle change. Each level decides what to do with its child's transition — either pass it along (lifted into its own transition type) or handle it locally.
Debouncing with VersionedInput
One more pattern comes up inside the favourites workflow. When the user types in the "add favourite" search box, we want to fetch geocoding results — but we don't want a response for "Londo" to replace a response for "London" that arrives moments later. The answer is a small helper:
#![allow(unused)] fn main() { /// A text input that tracks a version number, incremented on each update. /// /// Used to correlate async responses (e.g. search results) with the input /// that triggered them, so stale responses can be discarded. Capture the /// version when an effect is started, then check it against the current /// version via [`Self::is_current`] when the response arrives. #[derive(Debug, Default)] pub struct VersionedInput { version: usize, value: String, } impl VersionedInput { /// Updates the input value and bumps the version, returning the new /// version. pub fn update(&mut self, value: String) -> usize { self.version = self.version.wrapping_add(1); self.value = value; self.version } /// Returns the current input text. pub fn value(&self) -> &str { &self.value } /// Returns the current version number. pub fn version(&self) -> usize { self.version } /// Whether the given version matches the current one — used to discard /// responses from stale inputs. pub fn is_current(&self, version: usize) -> bool { self.version == version } } }
Every keystroke bumps the version. When we fire the geocoding request, we capture the current version. When the response arrives, we check whether the captured version still matches — if not, a newer search has happened, so we discard this result.
This isn't a state machine on its own, but the discipline is the same: make invalid states impossible to represent. Without a version, a stale response and a fresh one are both strings, and the code has to track out-of-band which is which. Tagging each response with the version it was fired against moves that distinction into the type. VersionedInput is used inside the add-favourite workflow, which is itself a nested state machine under favourites management.
Next: making it all happen
So far we've modelled the state machines and talked about the commands they return, but we've treated commands as a black box — just "the thing that makes effects happen." In the next chapter, we'll look at the Command type properly: how effects are expressed, how commands compose, and how the protocol between the core and the shell actually works.