Almost every large app with a multi-step flow has a class whose name ends in Context, Session, Manager or DataHolder. In a travel app it is BookingFlowContext, in retail it is CheckoutSession, in banking it is TransferData. It has around thirty optional properties, every screen in the flow reads it, and most of them write to it. Nobody designed it. It accumulated.
We will use a hypothetical travel booking app throughout: search, flight selection, passenger details, extras, payment. The article covers how the bag is born, what exactly it breaks, and how we take it apart without stopping feature work.
The bag starts as a convenience
In the first release the search screen passes three parameters to the results screen. Then the payment screen needs the passenger count, and there are two screens in between. Somebody suggests putting it "somewhere shared" so it does not have to be threaded through two screens by hand. Once that shared place exists, every new requirement goes to the same address, because it is the cheapest route. Two years later you have this:
final class BookingFlowContext: ObservableObject {
static let shared = BookingFlowContext()
@Published var origin: Airport?
@Published var destination: Airport?
var departureDate: Date?
var returnDate: Date?
var isRoundTrip = false
var passengerCount: Int?
var searchResults: [Offer]?
@Published var selectedOffer: Offer?
var selectedReturnOffer: Offer?
var passengers: [Passenger]?
var contactEmail: String?
var selectedSeats: [String: String]? // passenger id -> seat
var baggage: [BaggageOption]?
var insuranceSelected: Bool?
var promoCode: String?
@Published var totalPrice: Decimal?
var currency: String?
var paymentToken: String?
var isFromDeepLink = false
var isEditingFromSummary = false
var shouldSkipExtras: Bool?
// ... ten or so more
func reset() { /* sets each field to nil by hand; the newest field is usually missing */ }
}
On Android the same class is usually registered in the DI container as a @Singleton, which makes it look more respectable without changing what it is:
@Singleton
class BookingFlowContext @Inject constructor() {
var origin: Airport? = null
var destination: Airport? = null
var departureDate: LocalDate? = null
var passengerCount: Int? = null
var selectedOffer: Offer? = null
var passengers: List<Passenger>? = null
var selectedSeats: MutableMap<String, String>? = null
var promoCode: String? = null
var totalPrice: BigDecimal? = null
var paymentToken: String? = null
var isFromDeepLink: Boolean = false
var isEditingFromSummary: Boolean = false
// ...
fun clear() { /* same story */ }
}
class PaymentViewModel @Inject constructor(
private val context: BookingFlowContext,
private val payments: PaymentRepository,
) : ViewModel() {
fun pay() = viewModelScope.launch {
payments.pay(
offerId = context.selectedOffer!!.id,
passengers = context.passengers!!,
amount = context.totalPrice!!,
)
}
}
This is a global variable dressed up as constructor injection. Everything we wrote about singletons applies here, and the flow adds problems of its own on top.
What exactly breaks
Optional explosion and force unwraps
Every field in the bag is optional, because at the start of the flow none of them exist. From the payment screen's point of view, selectedOffer definitely exists; you could not have reached the screen otherwise. The type system does not carry that knowledge, so the developer asserts it with ! or !!. The PaymentViewModel above makes the assumption "you can only get here through these screens" in three places, and that assumption is written down nowhere.
The defensive team uses guard let and returns quietly. The outcome is worse: the pay button does nothing, and no crash report arrives to tell you.
Temporal coupling
Screen C assumes screen B has run. That holds only for happy-path navigation. The day product asks for "users with saved travellers skip the passenger screen", you find out in production that the passenger screen was the one writing contactEmail and the payment screen was the one reading it. The compiler cannot know the order, because the order is not in the types. It exists only in the sequence in which screens happen to open.
No way to know who wrote a field
If "Find Usages" on totalPrice returns fourteen write sites, debugging a wrong price means fourteen breakpoints. A field in the bag has no owner. The extras screen updates the price, the promo code service updates it, and so does the background price refresh. Last writer wins.
Stale data after back navigation or a changed selection
The user picks seat 14A on the extras screen, goes back, and selects a different flight. The bag now holds the new flight in selectedOffer and the old aircraft's seat map in selectedSeats. The only defence is to know by hand which fields become invalid on each change of selection and clear them. This is why the "forgot to nil this one too" commits inside reset() never stop coming.
Deep links and state restoration enter mid-flow
A "complete your payment" notification wants to take the user straight to the payment screen. The bag is empty. The usual fix is a deep link handler that fills the bag by hand: a function that imitates the side effects of the previous four screens and silently rots as those screens change. This is also the moment isFromDeepLink enters the bag, and screens start branching on it.
Two writers
The price refresh request is in flight while the user adds baggage. Two writes hit the same field from different threads. In Swift 6 language mode the compiler flags a static let shared instance of a non-Sendable class, and teams typically silence it with @unchecked Sendable or nonisolated(unsafe); the problem stays where it was. In Kotlin, writing plain var fields from Dispatchers.IO compiles, runs, and occasionally shows the wrong total.
Process death on Android
The user is on the payment screen, switches to the banking app and waits for an SMS code. The system kills your process to reclaim memory. When the user returns, Navigation restores the back stack from saved state and opens the payment screen. The @Singleton bag, however, has been recreated empty in the new process. context.selectedOffer!! throws a NullPointerException. Trying this on your own project takes a minute: go to the middle of the flow, put the app in the background and run:
adb shell am kill com.example.travel
Then reopen the app from the recents list. Most flows built on a bag do not survive this test.
Tests need the whole bag
Testing the payment screen really requires one offer and one amount. With a view model that works off the bag, the test has to work out from the source which eleven fields are read and fill all of them. Miss one and the test crashes or, worse, passes without asserting anything because a guard let bailed out.
Why "we'll just make it observable" does not fix it
When the first complaints about the bag arrive, the first patch proposed is to make it reactive: @Published, @Observable or a StateFlow<BookingContext>. Now screens are notified of changes. Being notified solves none of the problems above. The fields are still optional, which field must exist at which step is still unwritten, the writer is still unknown, and process death still wipes everything.
New problems arrive with it. When two fields that must change together (selectedOffer and totalPrice) are published separately, observers see the intermediate state: new flight, old price. Once a screen observes the bag and writes to the bag in response, you get feedback loops, and "why is the price calculated twice" eats half a sprint. Reactivity puts a notification mechanism on top of shared mutable state. It defines neither ownership nor validity.
What we do instead
We have two rules.
Each step takes the data it needs as an immutable input and returns what it produced as an explicit output.
Accumulated state has exactly one owner, and that state carries in its type the data that must exist at each point.
The owner of the flow can be a coordinator or flow model on iOS, the navigation host or a reducer on Android. Screens do not know about each other, and the owner does not know what goes on inside a screen. If the payment screen's input is a Quote, then somebody must have produced a Quote for that screen to exist at all. "Did B run" is answered at compile time.
In this model the state of the flow is a state machine, and each state carries only the data that definitely exists at that point. A Swift enum with associated values and a Kotlin sealed interface are the natural fit. The optionals go away, because "not there yet" is a different case.
iOS: the path is the state machine
NavigationStack can bind to a collection of Hashable values, and that collection drives the stack. Since we use a single route type, we prefer a plain array to the type-erased NavigationPath; the switch stays exhaustive.
struct SearchCriteria: Hashable, Codable {
let origin: String // IATA code
let destination: String
let date: Date
let passengerCount: Int
}
enum BookingStep: Hashable {
case results(SearchCriteria)
case passengers(Offer) // the selected offer
case extras(BookingDraft) // server-side draft: offer + passengers
case payment(Quote) // price-locked, time-limited quote
case confirmation(BookingReference)
}
Each case carries the output of the one before it. .payment holds no passenger list, because the payment screen does not need one; a Quote is enough. The owner of the flow looks like this:
@MainActor @Observable
final class BookingFlow {
var path: [BookingStep] = []
func searchSubmitted(_ criteria: SearchCriteria) {
path = [.results(criteria)]
}
func offerSelected(_ offer: Offer, from origin: BookingStep) {
advance(from: origin, to: .passengers(offer))
}
func passengersSaved(_ draft: BookingDraft, from origin: BookingStep) {
advance(from: origin, to: .extras(draft))
}
func extrasPriced(_ quote: Quote, from origin: BookingStep) {
advance(from: origin, to: .payment(quote))
}
func paid(_ reference: BookingReference) {
// Back must not return to the payment screen: replace the stack
path = [.confirmation(reference)]
}
private func advance(from origin: BookingStep, to next: BookingStep) {
// If the step that produced this output is no longer on the stack
// (the user went back, the request came home late), drop the result
guard let index = path.lastIndex(of: origin) else { return }
path.removeSubrange((index + 1)...)
path.append(next)
}
}
advance closes two problems at once. When the user goes back and selects another flight, the old .extras step leaves the stack and the seat selection inside it goes with it; there is no field left to clear. A late async result cannot push the flow forward either, because the step that started it is no longer on the stack. The same mechanism stops a double tap from pushing the same screen twice.
The view builds each step from its input and wires its output to the flow:
struct BookingFlowView: View {
@State private var flow = BookingFlow()
let services: BookingServices
var body: some View {
NavigationStack(path: $flow.path) {
SearchView(onSubmit: flow.searchSubmitted)
.navigationDestination(for: BookingStep.self) { step in
switch step {
case .results(let criteria):
ResultsView(criteria: criteria, offers: services.offers) { offer in
flow.offerSelected(offer, from: step)
}
case .passengers(let offer):
PassengersView(offer: offer, drafts: services.drafts) { draft in
flow.passengersSaved(draft, from: step)
}
case .extras(let draft):
ExtrasView(draft: draft, pricing: services.pricing) { quote in
flow.extrasPriced(quote, from: step)
}
case .payment(let quote):
PaymentView(quote: quote, payments: services.payments, onPaid: flow.paid)
case .confirmation(let reference):
ConfirmationView(reference: reference)
}
}
}
}
}
The payment view model now knows only its own input, and its test is correspondingly small:
@MainActor @Observable
final class PaymentViewModel {
let quote: Quote
private(set) var isPaying = false
private(set) var failure: PaymentFailure?
private let payments: PaymentService
private let onPaid: (BookingReference) -> Void
init(quote: Quote, payments: PaymentService, onPaid: @escaping (BookingReference) -> Void) {
self.quote = quote
self.payments = payments
self.onPaid = onPaid
}
func pay(with method: PaymentMethod) async {
isPaying = true
defer { isPaying = false }
do {
onPaid(try await payments.pay(quoteID: quote.id, method: method))
} catch {
failure = PaymentFailure(error)
}
}
}
@Test @MainActor
func successfulPaymentReturnsReference() async {
let quote = Quote(id: "q-1", draftID: "d-1", total: Money(amount: 1250, currency: "TRY"), expiresAt: .distantFuture)
var received: BookingReference?
let viewModel = PaymentViewModel(
quote: quote,
payments: PaymentServiceStub(result: BookingReference("PNR123")),
onPaid: { received = $0 }
)
await viewModel.pay(with: .savedCard(id: "c-1"))
#expect(received == BookingReference("PNR123"))
}
No force unwraps, no bag, no eleven fields to populate in the test. If you want the passenger details a user already typed to survive a change of flight, that is no longer something that happens by accident: you add an explicit prefill parameter to the input of PassengersView, and the place where the decision is made is visible in code.
Android: typed routes and SavedStateHandle
Since 2.8.0, Navigation Compose supports routes defined as @Serializable types: composable<T>, navigation<T>, toRoute() and a typed popUpTo. We group the routes under a sealed interface; each one carries the data required for that step to exist.
@Serializable data object BookingGraph
@Serializable
sealed interface BookingRoute {
@Serializable data object Search : BookingRoute
@Serializable data class Results(
val origin: String,
val destination: String,
val date: String, // ISO-8601
val passengerCount: Int,
) : BookingRoute
@Serializable data class Passengers(val offerId: String) : BookingRoute
@Serializable data class Extras(val draftId: String) : BookingRoute
@Serializable data class Payment(val quoteId: String) : BookingRoute
@Serializable data class Confirmation(val reference: String) : BookingRoute
}
You will have noticed the difference from iOS: here the route carries the identity of the object, where the Swift version carried the object. The platform is the reason. Navigation writes the back stack and its arguments into saved state, and the Android documentation is explicit that saved state is for small, simple data and that you should store IDs in place of complex objects. Sticking to primitive fields also spares you from writing a custom NavType.
The navigation host owns the flow, and every transition can be read in one file:
@Composable
fun BookingNavHost(navController: NavHostController = rememberNavController()) {
NavHost(navController, startDestination = BookingGraph) {
navigation<BookingGraph>(startDestination = BookingRoute.Search) {
composable<BookingRoute.Search> {
SearchScreen(onSubmit = { c ->
navController.navigate(
BookingRoute.Results(c.origin, c.destination, c.date.toString(), c.passengerCount)
)
})
}
composable<BookingRoute.Results> {
ResultsScreen(onOfferSelected = { offerId ->
navController.navigate(BookingRoute.Passengers(offerId))
})
}
composable<BookingRoute.Passengers> {
PassengersScreen(onDraftSaved = { draftId ->
navController.navigate(BookingRoute.Extras(draftId))
})
}
composable<BookingRoute.Extras> {
ExtrasScreen(onPriced = { quoteId ->
navController.navigate(BookingRoute.Payment(quoteId))
})
}
composable<BookingRoute.Payment>(
deepLinks = listOf(
navDeepLink<BookingRoute.Payment>(basePath = "https://travel.example.com/pay")
)
) {
PaymentScreen(onPaid = { reference ->
navController.navigate(BookingRoute.Confirmation(reference)) {
// Back must not return to the payment screen
popUpTo<BookingRoute.Search> { inclusive = true }
}
})
}
composable<BookingRoute.Confirmation> { entry ->
ConfirmationScreen(reference = entry.toRoute<BookingRoute.Confirmation>().reference)
}
}
}
}
Each screen's view model reads its input through SavedStateHandle. Navigation places the route arguments into the handle for you:
sealed interface PaymentUiState {
data object Loading : PaymentUiState
data class Ready(val quote: Quote, val paying: Boolean = false) : PaymentUiState
data object QuoteExpired : PaymentUiState
data class Paid(val reference: String) : PaymentUiState
}
@HiltViewModel
class PaymentViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val quotes: QuoteRepository,
private val payments: PaymentRepository,
) : ViewModel() {
private val route = savedStateHandle.toRoute<BookingRoute.Payment>()
private val _state = MutableStateFlow<PaymentUiState>(PaymentUiState.Loading)
val state: StateFlow<PaymentUiState> = _state.asStateFlow()
init {
viewModelScope.launch {
val quote = quotes.find(route.quoteId)
_state.value = if (quote == null || quote.isExpired()) {
PaymentUiState.QuoteExpired
} else {
PaymentUiState.Ready(quote)
}
}
}
fun pay(method: PaymentMethod) {
val ready = _state.value as? PaymentUiState.Ready ?: return
if (ready.paying) return
_state.value = ready.copy(paying = true)
viewModelScope.launch {
_state.value = try {
PaymentUiState.Paid(payments.pay(ready.quote.id, method).reference)
} catch (e: PaymentException) {
ready.copy(paying = false)
}
}
}
}
No !!. An expired quote is a state the screen can render, where before it was a crash. Whether the payment screen was opened from a deep link, from restoration after process death or through the normal flow, the same code runs, because its only input is quoteId. The isFromDeepLink flag has no reason to exist.
The accumulated data (passengers, extras) lives in a draft on the server or in the local database. That is different from moving the bag into a database. The passenger step creates the draft and returns a draftId; the extras step cannot exist without that ID. The order is still in the types.
If you are starting a new project, look at Navigation 3 as well. There the back stack is a list of keys that you own, and the model comes very close to the iOS code above. In an existing Navigation Compose project, everything in this article can be done with typed routes; you do not need to change navigation libraries to take the bag apart.
Persisting and restoring a flow correctly
The rule is the same on both platforms: saved state holds the position in the flow and the IDs, while price and availability are always fetched again. Opening a payment screen with a stale price is a more expensive mistake than sending the user back to the start.
On Android most of this comes for free. rememberNavController saves the back stack together with the route arguments; after process death PaymentViewModel is recreated with the same quoteId and loads the quote from the repository. For form input the user is typing but has not submitted, we use rememberSaveable or SavedStateHandle; the saved delegate that arrived with Lifecycle 2.9 can store a @Serializable form draft directly. Keep in mind that saved state is written only when the Activity is stopped and is discarded when the user swipes the app away from recents. A booking that should be resumable days later needs persistent storage.
On iOS the system does not do this for you. Because BookingStep carries full objects, we do not want to write the path to disk as it is. We produce a small snapshot from it:
struct BookingSnapshot: Codable {
enum Position: Codable {
case results
case passengers(offerID: String)
case extras(draftID: String)
case payment(quoteID: String)
}
let criteria: SearchCriteria
let position: Position
}
extension BookingFlow {
func restore(_ snapshot: BookingSnapshot, using services: BookingServices) async {
let results = BookingStep.results(snapshot.criteria)
do {
switch snapshot.position {
case .results:
path = [results]
case .passengers(let offerID):
let offer = try await services.offers.offer(id: offerID)
path = [results, .passengers(offer)]
case .extras(let draftID):
let draft = try await services.drafts.draft(id: draftID)
path = [results, .passengers(draft.offer), .extras(draft)]
case .payment(let quoteID):
let quote = try await services.pricing.quote(id: quoteID)
let draft = try await services.drafts.draft(id: quote.draftID)
path = quote.expiresAt > .now
? [results, .passengers(draft.offer), .extras(draft), .payment(quote)]
: [results, .passengers(draft.offer), .extras(draft)]
}
} catch {
// The offer or draft is gone: fall back to the furthest point that is still valid
path = [results]
}
}
}
Writing the snapshot into @SceneStorage as Data whenever the path changes, and calling restore when the scene appears, is all it takes. Apple asks that SceneStorage data be lightweight and not sensitive, which is why the snapshot contains only the search criteria and IDs. Passenger names and passport numbers stay in the server-side draft. The same restore function also serves deep links: a "complete your payment" notification is nothing more than a .payment(quoteID:) snapshot.
Removing the bag field by field
Do not try to remove the bag in one pull request. A three-week branch opens, feature teams add four more fields to the bag in the meantime, and everyone regrets merge day. This is the order we follow:
- Freeze the bag first: no new fields. Do not leave this as a meeting decision; a simple CI check that counts the properties in the file is enough. Take your baseline at the same time:
grep -rn "BookingFlowContext" --include="*.swift" | wc -l, plus the number of!/!!in the flow code. - Make writes visible. Mark the fields
private(set)and move every write into a named method (applyPromo(_:),selectOffer(_:)). Behaviour does not change, but you now hold the list of who writes what. This step tends to surface two or three bugs on its own. - Start from the end of the flow. Confirmation and payment read the most fields and have the fewest screens depending on them. Give the screen an explicit input type and build that input in the flow owner, at navigation time, from the bag. The screen no longer imports the bag. The force unwraps collect in a single adapter function, where they turn into a real error path.
- Turn the outputs around. The screen returns its result through a closure or callback, and only the flow owner writes to the bag. After this step the bag has one writer.
- Work towards the start of the flow. As each screen moves over, some fields lose their last reader; delete the field in the same PR. The bag gets a little smaller every week, and that is progress people can see.
- Finally, replace the bag inside the owner with the state machine. Since only one class touches it by now, this is a local refactoring.
Every new step written while the migration is under way gets an input and an output from day one, even if the owner builds that input from the bag for now. Feature work does not stop, and the debt does not grow.
You know you are done when the type is deleted and reset() went with it. There are no force unwraps on flow data in the flow code. The test for any screen constructs only that screen's input. A payment deep link works from a cold start. On Android the am kill test passes at every step of the flow.
Where it is not worth it
Do not build a state machine for a two-screen flow; pass a parameter to the second screen. For a wizard whose steps are really pages of one form, a single form state struct with a single owner is fine, because the problems in this article come from sharing and from the absence of an owner. Things that really are app-wide, such as the session, the locale or feature flags, are not flow state and are outside the scope of this article. For the question of which data belongs to which model, see our article on separating shared and domain models.
Checklist
- No shared object made of optional fields that every screen in the flow reads and writes.
- Each step's input is an immutable type; its output is explicitly defined as a closure, callback or return value.
- Accumulated state has one owner, and each state carries only the data that exists at that point (enum / sealed interface).
- Going back and changing a selection drops the data of later steps by construction; no field is cleared by hand.
- Late async results do not advance the flow when the step that started them is no longer on the stack.
- No
!or!!on flow data in the flow code; missing data is a state the screen can render. - Saved state contains only position and IDs; price and availability are fetched again on restore.
- On Android, returning after
adb shell am killworks at every step of the flow. - The payment step can be entered by deep link from a cold start, with no
isFromDeepLink-style flag. - Each screen's test constructs only that screen's input.
Need support on this?
Let's review together whether these controls are set up correctly in your app. We're here with security audits and advisory.
Sources
- Apple · NavigationStack
- Apple · NavigationPath
- Apple · SceneStorage
- Apple · Restoring your app’s state with SwiftUI
- Swift.org · The Swift Programming Language, Enumerations
- Android · Type safety in Kotlin DSL and Navigation Compose
- Android · Nested graphs
- Android · Saved State module for ViewModel
- Android · Save UI states
- Kotlin · Sealed classes and interfaces
Comments and questions
Disagree with something, or stuck on this in your own project? Write to us and let's talk.