Most SwiftUI apps don’t break because of performance.
They break because of state explosion.
Symptoms:
- dozens of
@Statebooleans - impossible UI combinations
- bugs you “can’t reproduce”
- views that don’t know what they should show
- endless
if / else if / else
This post shows how to normalize UI state in SwiftUI so your app stays:
- correct
- predictable
- testable
- scalable
This is about state correctness, not just state storage.
🧠 The Core Problem: Boolean Hell
You’ve seen this:
@State var isLoading = false
@State var hasError = false
@State var isEmpty = false
@State var isRefreshing = false
@State var showRetry = false
Now ask yourself:
- Can
isLoadingandhasErrorboth be true? - What about
isEmptyandisLoading? - What does
showRetrymean ifhasErroris false?
These states allow invalid combinations.
SwiftUI will happily render them.
🧠 The Core Principle
If an invalid UI state is representable, it will eventually happen.
Your job is to make invalid states unrepresentable.
🧱 1. Model UI State as a Single Source of Truth
Instead of many booleans, use one enum:
enum ViewState<T> {
case idle
case loading
case success(T)
case empty
case failure(AppError)
}
Now the UI can only be in one valid state at a time.
🧭 2. Rendering Becomes Obvious
switch state {
case .idle:
EmptyView()
case .loading:
ProgressView()
case .success(let data):
ContentView(data: data)
case .empty:
EmptyStateView()
case .failure(let error):
ErrorView(error: error)
}
No ambiguity.
No contradictions.
No bugs hiding in boolean combinations.
🧬 3. Normalize Complex Screens into Sub-States
Real screens are more complex.
Bad approach:
@State var isEditing = false
@State var isSaving = false
@State var hasValidationError = false
@State var showConfirmation = false
Normalized approach:
enum EditorState {
case viewing
case editing
case validating
case saving
case saved
case error(AppError)
}
Transitions become explicit.
🔁 4. State Transitions Should Be Explicit
Instead of mutating flags everywhere:
state = .editing
state = .saving
state = .saved
Define transitions:
func submit() {
guard state == .editing else { return }
state = .validating
}
func validationPassed() {
guard state == .validating else { return }
state = .saving
}
This prevents illegal transitions.
🧠 5. Derived State vs Source-of-Truth State
Never store derived state.
Bad:
@State var isLoggedIn: Bool
@State var user: User?
These can drift out of sync.
Good:
enum SessionState {
case loggedOut
case loggedIn(User)
}
Derived values:
var isLoggedIn: Bool {
if case .loggedIn = session { return true }
return false
}
One source of truth. Zero drift.
🧩 6. Split Independent State Machines
If two parts of the UI are independent, model them independently.
Example:
struct ScreenState {
var content: ViewState<[Item]>
var refresh: RefreshState
}
enum RefreshState {
case idle
case refreshing
case failed
}
This avoids overloading one giant enum.
🧪 7. Testing Becomes Trivial
func testEmptyState() {
let vm = ViewModel()
vm.state = .empty
XCTAssertEqual(vm.state, .empty)
}
You don’t test combinations.
You test valid states.
⚠️ 8. Avoid the “Mega Enum” Trap
Bad:
enum AppState {
case loading
case home(HomeState)
case profile(ProfileState)
case settings(SettingsState)
}
This becomes unmaintainable.
Rule:
- Normalize per screen / feature
- Compose state, don’t centralize everything
❌ 9. Common Anti-Patterns
Avoid:
- boolean flags for UI modes
- storing derived state
- updating state from views
- allowing illegal transitions
- “temporary” flags that never go away
- dumping everything in AppState
These always come back to haunt you.
🧠 Mental Model
Think in state machines, not variables.
Idle → Loading → Success
↓
Error
If you can’t draw it, your state is wrong.
🚀 Final Thoughts
State normalization gives you:
- fewer bugs
- clearer UI logic
- simpler views
- easier testing
- safer refactors
- confidence at scale
Most SwiftUI “bugs” are actually state design bugs.
Fix the state model, and the UI fixes itself.
Top comments (0)