App lifecycle
As we think about the weather app, there's an overall workflow it moves through:
Uninitialized— the default; the core exists, but the shell hasn't kicked things off yet.Initializing— triggered byEvent::Start: retrieves resources that may have been saved previously (the API key, saved favourites).Onboard— if there's no API key, ask the user for one.Active— we have everything we need; the app is running normally.Failed— something went wrong that we can't recover from.
These phases are mutually exclusive — the app is always in exactly one of them — which makes a Rust enum the natural fit. Each variant holds the state for its phase, and we can focus on one at a time with its own events and transitions.
The shape of the lifecycle
#![allow(unused)] fn main() { /// The app's top-level lifecycle state machine. /// /// The app moves between mutually exclusive phases: it starts uninitialised, /// fetches stored data during initialisation, then either onboards the user /// (if no API key is stored) or activates. From active, a 401 response or a /// user-initiated reset sends it back to onboarding. #[derive(Default, Debug)] pub enum Model { /// The default state before the shell sends `Event::Start`. The core /// exists but has not begun any work yet. #[default] Uninitialized, /// Shell sent `Event::Start`; fetching the API key and favourites in /// parallel. Initializing(InitializingModel), /// No API key available; prompting the user for one. Entered on first /// run, after a 401, or on explicit reset. Onboard(OnboardModel), /// API key and favourites loaded; running the main app. Active(ActiveModel), /// Unrecoverable error; carrying a message for the UI. Failed(String), } }
The events driving it are namespaced by stage:
#![allow(unused)] fn main() { /// The top-level event type, namespaced by lifecycle stage. /// /// `Start` kicks the app out of `Uninitialized`. The remaining variants carry /// sub-events for the stage currently in progress. `Initializing` is internal /// to the core and not visible to the shell. #[derive(Facet, Serialize, Deserialize, Clone, Debug, PartialEq)] #[repr(C)] pub enum Event { /// Sent by the shell once, at launch. Triggers initialisation. Start, /// Sub-events for the onboarding flow. Onboard(OnboardEvent), /// Sub-events for the active app (home and favourites). Active(ActiveEvent), /// Internal events resolving the parallel initialisation fetches. #[serde(skip)] #[facet(skip)] Initializing(InitializingEvent), } }
Event::Start is the only event that kicks the app out of Uninitialized; the rest carry sub-events for a specific stage. Initializing is marked #[serde(skip)] and #[facet(skip)] because those events are internal to the core — the shell never sends them.
Kicking things off
The top-level update function is small — it just decides which handler to dispatch to:
#![allow(unused)] fn main() { pub fn update(&mut self, event: Event) -> Command<Effect, Event> { match event { Event::Start => { let (initializing, cmd) = InitializingModel::start().into_parts(); *self = Model::Initializing(initializing); cmd } Event::Initializing(event) => self.update_initializing(event), Event::Onboard(event) => self.update_onboard(event), Event::Active(event) => self.update_active(event), } } }
Event::Start builds the Initializing state by calling InitializingModel::start(), which returns the initial model and the commands to run. Everything else is routed to a stage-specific update_* method.
But the core doesn't run itself — the shell has to send that Event::Start to begin with. Here are the iOS and Android shells doing exactly that:
init() {
let bridge = LiveBridge()
let core = Core(bridge: bridge)
_core = State(wrappedValue: core)
updater = CoreUpdater { core.update($0) }
core.update(.start)
}
init {
update(Event.Start)
}
In both cases the shell constructs the core, wires up its dependencies, and then immediately sends Event::Start — nothing else happens until the shell makes that first call. That's the "core is driven" point from chapter 2 in practice: the core is just a library until the shell pokes it.
The transition pattern
Event::Start does its own transition right in the top-level update — it constructs the Initializing model and assigns it directly. For the other events, the top-level update delegates to a stage-specific handler. Those handlers all share the same shape, and it's worth looking at once before we dive into initialising.
Here's update_initializing:
#![allow(unused)] fn main() { fn update_initializing(&mut self, event: InitializingEvent) -> Command<Effect, Event> { let owned = std::mem::take(self); let Model::Initializing(initializing) = owned else { *self = owned; return Command::done(); }; let (status, command) = initializing .update(event) .map_event(Event::Initializing) .into_parts(); match status { outcome::Status::Continue(initializing) => { *self = Model::Initializing(initializing); command } outcome::Status::Complete(initializing::InitializingTransition::Onboard(favorites)) => { let (onboard, start_cmd) = OnboardModel::start(onboard::OnboardReason::default(), favorites) .map_event(Event::Onboard) .into_parts(); *self = Model::Onboard(onboard); command.and(start_cmd) } outcome::Status::Complete(initializing::InitializingTransition::Active( api_key, favorites, )) => { let (active, start_cmd) = ActiveModel::start(api_key, favorites) .map_event(Event::Active) .into_parts(); *self = Model::Active(active); command.and(start_cmd) } } } }
Three moves:
- Take ownership of the current model with
std::mem::take. BecauseModelderivesDefault, this leavesselftemporarily asUninitialized— we're about to replace it, so that's fine. - Delegate to the stage-specific update, which returns an
Outcome<State, Transition, Event>. TheOutcomepairs aStatus— eitherContinue(State)to stay in this phase orComplete(Transition)to exit — with aCommandthat represents the effects of the update. - Put a model back. For
Continue, wrap the updated state back into the current phase. ForComplete, construct the next phase's model and swap to it.
This mem::take → delegate → reassign shape takes advantage of Rust's ownership model. The stage-specific update takes self by value, so the model moves in, transforms, and comes back through Outcome — no cloning, with the type system enforcing that we reconstruct a model to put back. The Outcome itself is the protocol that tells the top level which phase comes next. We'll apply it to initialising in the next section, and in chapter 4 we'll see it used at every level inside Active too.
Initialising: two fetches in parallel
Constructing an InitializingModel isn't just about the state — we also need to fire off the two fetches. A Default impl would give us the state, but nothing would actually start running. So instead we have a start() method that returns both the initial model and the commands to run alongside it, paired up as a Started<Self, Event>:
#![allow(unused)] fn main() { pub(crate) fn start() -> Started<Self, Event> { tracing::debug!("starting initialization, fetching API key and favorites"); let fetch_secret = secret::command::fetch(secret::API_KEY_NAME) .then_send(|r| Event::Initializing(InitializingEvent::SecretFetched(r))); let fetch_favorites = KeyValue::get(FAVORITES_KEY) .then_send(|r| Event::Initializing(InitializingEvent::FavoritesLoaded(r))); Started::new( Self::default(), Command::all([fetch_secret, fetch_favorites]), ) } }
Two commands, kicked off in parallel with Command::all: one to fetch the API key, one to read the favourites list from the KV store. Each binds its response to a specific InitializingEvent variant. We'll see more of the Started pattern in the next chapter.
Meanwhile, the state we're waiting in:
#![allow(unused)] fn main() { /// A value that's either still being fetched or has been fetched. #[derive(Default, Debug)] enum InitializingValue<T> { #[default] Fetching, Fetched(T), } /// The state held while the app is initialising. /// /// Two fetches run in parallel — the API key from secure storage and the /// favourites list from the KV store. Each is tracked independently so the /// model knows when both have resolved. #[derive(Default, Debug)] pub struct InitializingModel { api_key: InitializingValue<Option<ApiKey>>, favorites: InitializingValue<Favorites>, } }
When a response comes back, it flows through update:
#![allow(unused)] fn main() { pub(crate) fn update( mut self, event: InitializingEvent, ) -> Outcome<Self, InitializingTransition, InitializingEvent> { match event { InitializingEvent::SecretFetched(response) => { let api_key = match response { SecretFetchResponse::Missing(_) => { tracing::debug!("API key missing"); None } SecretFetchResponse::Fetched(api_key) => { tracing::debug!("received API key"); Some(api_key.into()) } }; self.api_key = InitializingValue::Fetched(api_key); self.resolve() } InitializingEvent::FavoritesLoaded(result) => { let favorites = result .ok() .flatten() .and_then(|bytes| serde_json::from_slice::<Vec<Favorite>>(&bytes).ok()) .map(Favorites::from_vec) .unwrap_or_default(); tracing::debug!("loaded {} favorites", favorites.len()); self.favorites = InitializingValue::Fetched(favorites); self.resolve() } } } }
Each branch stores the result, then calls resolve() to see whether we have enough to move on:
#![allow(unused)] fn main() { fn resolve(self) -> Outcome<Self, InitializingTransition, InitializingEvent> { match (self.api_key, self.favorites) { (InitializingValue::Fetched(Some(api_key)), InitializingValue::Fetched(favorites)) => { tracing::debug!("initialization complete, transitioning to active"); Outcome::complete( InitializingTransition::Active(api_key, favorites), Command::done(), ) } (InitializingValue::Fetched(None), InitializingValue::Fetched(favorites)) => { tracing::debug!("API key missing, transitioning to onboarding"); Outcome::complete(InitializingTransition::Onboard(favorites), Command::done()) } (api_key, favorites) => { tracing::debug!("waiting for remaining initialization data"); Outcome::continuing(Self { api_key, favorites }, render()) } } } }
Three cases:
- Both fetched, key present →
Completewith a transition toActive. - Both fetched, key missing →
Completewith a transition toOnboard. - One still in flight →
Continuewith the updated state, and ask for a render so the loading screen keeps showing.
Back in the top-level update_initializing, both Complete cases follow the same shape: call the destination stage's start(), swap to the new Model variant, and compose the commands. OnboardModel::start returns a render so the onboarding screen appears; ActiveModel::start wraps HomeScreen::start to kick off the weather and location fetches.
Onboard, Active, and Failed
Onboard looks much like Initializing: its own model, its own events, its own update, and its own transitions. When the user enters an API key and it's stored successfully, it transitions to Active. If storage fails, it transitions to Failed.
Active is where most of the app lives — the home screen with local weather and favourites, and the favourites management screen. That's the subject of the next chapter.
Failed is a dead end. It just carries a message for the UI to show. There's no event that leaves it.
Back to onboarding
Not every lifecycle transition goes forward. Two things can send Active back to Onboard: the weather API returning a 401 (the stored key is bad), or the user explicitly asking to reset their key. Either way, Active completes with a transition carrying the current favourites and an OnboardReason — the onboarding flow is the same one we saw on first run; the reason is only used to pick the right message for the UI.
Next: the pattern underneath
Every stage in this lifecycle — Initializing, Onboard, Active — returned an Outcome. The top-level update_* methods all matched on Status::Continue vs Status::Complete(...), put the model back where it belongs, and composed commands. The same pattern runs all the way down to the individual screen workflows, which the next chapter covers.