DEV Community

Sebastien Lato
Sebastien Lato

Posted on

SwiftUI State Explosion & State Normalization (Correctness at Scale)

Most SwiftUI apps don’t break because of performance.

They break because of state explosion.

Symptoms:

  • dozens of @State booleans
  • 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
Enter fullscreen mode Exit fullscreen mode

Now ask yourself:

  • Can isLoading and hasError both be true?
  • What about isEmpty and isLoading?
  • What does showRetry mean if hasError is 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)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Normalized approach:

enum EditorState {
    case viewing
    case editing
    case validating
    case saving
    case saved
    case error(AppError)
}
Enter fullscreen mode Exit fullscreen mode

Transitions become explicit.


🔁 4. State Transitions Should Be Explicit

Instead of mutating flags everywhere:

state = .editing
state = .saving
state = .saved
Enter fullscreen mode Exit fullscreen mode

Define transitions:

func submit() {
    guard state == .editing else { return }
    state = .validating
}
Enter fullscreen mode Exit fullscreen mode
func validationPassed() {
    guard state == .validating else { return }
    state = .saving
}
Enter fullscreen mode Exit fullscreen mode

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.
Enter fullscreen mode Exit fullscreen mode

Good:

enum SessionState {
    case loggedOut
    case loggedIn(User)
}
Enter fullscreen mode Exit fullscreen mode

Derived values:

var isLoggedIn: Bool {
    if case .loggedIn = session { return true }
    return false
}
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode
enum RefreshState {
    case idle
    case refreshing
    case failed
}
Enter fullscreen mode Exit fullscreen mode

This avoids overloading one giant enum.


🧪 7. Testing Becomes Trivial

func testEmptyState() {
    let vm = ViewModel()
    vm.state = .empty

    XCTAssertEqual(vm.state, .empty)
}
Enter fullscreen mode Exit fullscreen mode

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)
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)