Enum and switch statements for reliable app states
Most iOS apps move through a predictable set of conditions: a profile is loading, a payment succeeds, an order is ready for collection, or a network request fails. Treating these conditions as unrelated booleans makes code difficult to reason about and creates invalid combinations, such as an empty basket that is simultaneously being submitted.
Swift’s enum gives each condition a name, while switch lets the compiler check that every case has been handled. Associated values can carry the exact data a state needs, so one type can replace scattered flags, optional properties and ad hoc callbacks. The result is UIKit code that is easier to test, extend and review.
Why app state needs an explicit model
Consider an account screen with properties such as isLoading, isSignedIn, user and errorMessage. Each property appears simple, but together they permit questionable combinations. The screen might be signed in while still showing a loading indicator, or it might display an error even though the request succeeded.
An enum expresses the mutually exclusive nature of these conditions. It tells the compiler that exactly one account state exists at a time and makes every possible state visible in one place:
enum AccountState {
case signedOut
case loading
case signedIn(User)
case failed(any Error)
}
This model also changes how features are designed. Instead of updating several properties from different network callbacks, a method produces the next AccountState. UIKit then renders that value. Invalid intermediate states become design errors rather than surprises discovered during testing.
Designing cases around real user journeys
Enum cases should describe meaningful stages in a user journey rather than implementation details. loading is useful for a short operation, while a checkout flow may need finer distinctions such as editing a basket, waiting for payment or holding a completed receipt. The right granularity comes from the decisions the interface must make.
Associated values let a case hold only the information required in that situation:
enum CheckoutState {
case idle
case editing(Basket)
case awaitingPayment(total: Decimal)
case completed(receipt: Receipt)
case failed(any Error)
}
Here, editing carries a basket, while awaitingPayment carries a total. A single case does not need unrelated data that would be irrelevant in other states.
Real journeys often need similarly precise variants. A customer ordering from a Melbourne café might choose a flat white with almond milk, select a pickup time and change the cup size. Those choices can be represented as associated values rather than separate isAlmondMilk, pickupTime and size properties. The state remains understandable even when the configuration becomes detailed.
Handling every case with an exhaustive switch
A switch over an enum makes state handling explicit. Because Swift knows the complete set of cases, it can report when a new case has been added but not processed. That compiler assistance is especially valuable in UIKit code, where forgotten branches often leave stale labels, buttons or progress indicators on screen.
switch state {
case .idle:
checkoutButton.isEnabled = false
case .editing(let basket):
itemCountLabel.text = "\(basket.items.count) items"
checkoutButton.isEnabled = !basket.items.isEmpty
case .awaitingPayment(let total):
totalLabel.text = total.formatted(.currency(code: "AUD"))
checkoutButton.isEnabled = true
case .completed(let receipt):
showReceipt(receipt)
case .failed(let error):
presentError(error)
}
For ordinary enums defined within an app, a catch-all default should usually be avoided because it can conceal a forgotten case. Swift’s @unknown default is different: it provides a defensive branch when switching over an enum from a library that may gain cases in a future version.
A Brisbane supermarket app can use this structure to distinguish an empty trolley, a saved trolley, payment in progress, collection confirmation and delivery confirmation. Each condition receives precise UI treatment instead of relying on a collection of loosely related checks.
Keeping UIKit in sync with state
In UIKit, the enum can become the view controller’s source of truth. A render(_:) method receives a state and updates the relevant controls, while user actions and callbacks send events back to the controller. This creates a predictable cycle: an event produces a state, and the state produces a view.
The pattern reduces scattered mutations. Without it, a delegate callback might change a label, another callback might change a spinner and a third might change a button, leaving the screen dependent on callback order. With state rendering, those updates occur together whenever the state changes.
State also makes configuration changes easier to handle. When an iPhone rotates, the controller can render its current state again rather than trying to reconstruct every property manually. If the state contains a Basket value, that value can be restored or passed to the next controller.
A Sydney transport app demonstrates the benefit. A current service might be onTime, delayed or cancelled, while a lookup failure is a separate state. When live departure information changes, the interface can replace the previous badge instead of accidentally retaining an outdated status after a failed refresh.
Separating progress, failure and recovery
Failures should not be treated as an afterthought attached to an otherwise successful state. They often contain information needed for recovery, such as a retry action, an error code or a field-level validation message. An associated value preserves that context:
enum SyncState {
case idle
case syncing(progress: Double)
case synced(at: Date)
case failed(reason: SyncFailure)
}
The switch can then present the right recovery option. A cancelled sync may allow an immediate retry, while a permission failure needs an explanation and a route to Settings. This is more dependable than showing every error through the same generic alert.
The same modelling discipline appears in infrastructure tools. A network diagram generator turns routing-table information into a clear visual representation, illustrating how structured data becomes easier to inspect when each condition has an explicit definition.
Formatting is another part of reliable state handling. Australian users commonly encounter day-month-year dates and 24-hour times in transport and booking apps. Use Date.FormatStyle and locale-aware formatters rather than manually assembling strings, so a state such as completed(at:) displays correctly without embedding Australian formatting rules throughout the controller.
Testing transitions instead of isolated views
Testing a view controller in isolation often produces brittle tests because it depends on timers, network calls and view lifecycle timing. Testing transitions gives the team a smaller, more stable unit to verify. Given a state and an event, a transition function should return the expected next state.
func reduce(
state: AccountState,
event: AccountEvent
) -> AccountState {
switch (state, event) {
case (.signedOut, .signInRequested):
return .loading
case (.loading, .signInSucceeded(let user)):
return .signedIn(user)
case (.loading, .signInFailed(let error)):
return .failed(error)
default:
return state
}
}
Tests can cover every valid pair of state and event, including repeated taps, a response arriving after cancellation and a retry after failure. This is easier to reason about than checking whether a particular label appeared several seconds after a mock network callback.
For a Kazakhstan-based perspective, the regional iOS community offers a useful place to compare Swift practices with those used in local app development. A small state-machine feature can also become a strong WWDC Swift Student Challenge project because it demonstrates both thoughtful product design and sound Swift architecture.
Building a practical state workflow
Begin by listing the meaningful conditions visible to the user. Remove duplicate names and identify which state needs extra information. Next, define the events that can change the state, such as refreshRequested, paymentSucceeded or retryTapped. Keep business decisions inside the transition function and visual decisions inside the rendering method.
Then test the transitions before connecting them to a screen. Xcode previews can display individual enum states, while unit tests verify the complete transition table. When a new case appears in Swift, the compiler identifies every switch that needs attention, which makes refactoring safer and faster.
The practical takeaway is to name every major condition as an enum case, handle it through an exhaustive switch and route changes through a small transition function. This keeps app state visible, testable and trustworthy as the interface evolves.