Why global state objects like BookingFlowContext are dangerous

The context bag with thirty optionals that every screen in a multi-step flow reads and writes is a convenience on day one and a crash source by year three. What it breaks, why making it observable does not help, and the typed input, output and state machine model we use instead, in Swift and Kotlin.

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.

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.
  6. 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 kill works 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.
Level 4 completeYour progress is kept in this browser.
Up next: Feature-based architecture vs layer-based architecture

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.

Comments and questions

Disagree with something, or stuck on this in your own project? Write to us and let's talk.