Skip to content
ExponaBLOG
SWIFT, SWIFTUI & IOS APPS · DAY 090 / 100

Error and loading states in SwiftUI: a step-by-step guide

Screens that handle every state.

Expona Admin6 min read

The problem with incomplete state handling

Most iOS apps fail gracefully in production because teams skip the intermediate states between user action and final result. Your network request succeeds in testing, the database call completes reliably in development, but users in the wild see blank screens, frozen spinners, or vague error messages. Error and loading states in SwiftUI: a step-by-step guide walks you through building screens that handle every state—idle, loading, loaded, and failed—so your app feels polished rather than brittle.

We'll cover a production-tested pattern that prevents flicker, gives users control through retry actions, and uses SwiftUI's built-in components to match system design. This isn't theory. We've shipped this approach across e-commerce checkouts, ride-hailing bookings, and SaaS dashboards where unreliable networks and timeout errors are the norm.

Model the state with an enum

The foundation of reliable error and loading state handling is a single source of truth. Define an enum that captures every phase of your data lifecycle. This prevents orphaned states where loading is true and data is nil and error exists—a contradiction your View logic shouldn't need to handle.

swift
enum LoadingState<T> {
    case idle
    case loading
    case loaded(T)
    case failed(Error)
}

Attach this to your ViewModel or StateObject. Let's build a concrete example: a user profile screen that fetches data from an API.

swift
@MainActor
class UserProfileViewModel: ObservableObject {
    @Published var state: LoadingState<User> = .idle
    
    func fetchProfile(id: String) async {
        state = .loading
        do {
            let user = try await apiClient.fetchUser(id: id)
            state = .loaded(user)
        } catch {
            state = .failed(error)
        }
    }
}

This pattern scales. Whether you're loading a single user, a list of products, or nested data structures, the enum enforces that your View can't accidentally render contradictory states. The compiler won't let you show a spinner and data simultaneously.

ContentUnavailableView—empty and error states with system styling

SwiftUI's ContentUnavailableView, introduced in iOS 17, eliminates the need to build custom error screens. It matches system design, adapts to light and dark modes automatically, and communicates missing data or failure clearly. For teams supporting earlier OS versions, we'll show the fallback too.

swift
@MainActor
struct UserProfileView: View {
    @StateObject var viewModel = UserProfileViewModel()
    
    var body: some View {
        switch viewModel.state {
        case .idle, .loading:
            ProgressView()
                .onAppear {
                    Task {
                        await viewModel.fetchProfile(id: "user-123")
                    }
                }
        
        case .loaded(let user):
            VStack(alignment: .leading, spacing: 16) {
                Text(user.name)
                    .font(.title2)
                    .fontWeight(.semibold)
                Text(user.email)
                    .foregroundColor(.secondary)
                Spacer()
            }
            .padding()
        
        case .failed(let error):
            ContentUnavailableView(
                label: {
                    Label("Unable to load profile", 
                          systemImage: "exclamationmark.triangle")
                },
                description: {
                    Text(error.localizedDescription)
                }
            )
        }
    }
}

ContentUnavailableView provides semantically correct layouts for empty and error states without writing custom layouts. The label and description are prominent and readable. Users immediately understand the problem, not guessing whether the app is frozen.

For iOS 16 or earlier, build a simple fallback:

swift
VStack(spacing: 12) {
    Image(systemName: "exclamationmark.triangle")
        .font(.system(size: 48))
        .foregroundColor(.secondary)
    
    Text("Unable to load profile")
        .font(.headline)
    
    Text(error.localizedDescription)
        .font(.subheadline)
        .foregroundColor(.secondary)
        .multilineTextAlignment(.center)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color(.systemBackground))

Retry—give users a clear retry action

Users will tap retry. Network glitches timeout on the first attempt and succeed on the second. A backend service briefly goes down. Offering a retry button isn't optional—it's essential UX. Make the action obvious and resettable.

swift
case .failed(let error):
    ContentUnavailableView(
        label: {
            Label("Unable to load profile", 
                  systemImage: "exclamationmark.triangle")
        },
        description: {
            Text(error.localizedDescription)
        },
        actions: {
            Button(action: {
                Task {
                    await viewModel.fetchProfile(id: "user-123")
                }
            }) {
                Text("Try again")
            }
            .buttonStyle(.borderedProminent)
        }
    )

When the user taps "Try again," reset the state to .loading so the View shows the spinner again. This feedback loop confirms the app is working, not ignoring the tap. A silent retry that takes five seconds leaves users confused—they think their tap didn't register.

For complex flows with multiple data dependencies, track which requests are in flight and let users retry individual failed segments without reloading everything.

Avoid flicker—delay spinners for very fast loads

Here's a detail that separates production apps from prototypes: showing a spinner for a 50ms network call creates jarring flicker. The screen flashes loading, loading, loaded in rapid succession. Users notice. It feels broken.

Delay the spinner. If the request completes in under 200ms (a typical threshold), skip the loading state entirely and jump straight to loaded. The user perceives instantaneous response.

swift
@MainActor
class UserProfileViewModel: ObservableObject {
    @Published var state: LoadingState<User> = .idle
    
    private let minimumLoadingDuration: TimeInterval = 0.2
    
    func fetchProfile(id: String) async {
        state = .loading
        let startTime = Date()
        
        do {
            let user = try await apiClient.fetchUser(id: id)
            
            let elapsedTime = Date().timeIntervalSince(startTime)
            let remainingDelay = max(0, minimumLoadingDuration - elapsedTime)
            
            try await Task.sleep(nanoseconds: UInt64(remainingDelay * 1_000_000_000))
            state = .loaded(user)
        } catch {
            state = .failed(error)
        }
    }
}

This ensures that even fast network calls show the spinner long enough to register as intentional feedback. On slower networks or larger datasets, the delay is negligible because the request naturally takes longer than 200ms. The user experience is consistently smooth, never flickering.

An alternative approach uses DispatchSourceTimer or a DispatchQueue delay, but the sleep-based pattern is cleaner in async/await code and integrates naturally with Task-based cancellation.

Common mistakes

  • Forgetting to handle .idle—New developers often treat idle and loading the same. If your View doesn't trigger the fetch on .idle, it'll sit blank until the user performs another action. Always attach .onAppear or use .task modifier to kick off the initial request.
  • Showing spinners without minimum duration—Flicker confuses users and makes your app feel jittery. A 0.2 to 0.5 second minimum hiding fast responses is the industry standard.
  • Losing the user ID or query on retry—If your retry button doesn't capture the original parameters, you'll retry with nil or stale data. Store the parameters in the ViewModel, not in the View.
  • Swallowing error details—"Something went wrong" helps no one. Expose the underlying error message or at least categorize it: network timeout, server error, invalid input. Users and support teams need context.
  • Not canceling in-flight requests on dealloc—If the user navigates away during a fetch, the Task should cancel automatically if you use .task(). But if you use manual Task creation, store it and cancel it in a deinit or .onDisappear handler to prevent memory leaks.

Key takeaways

  • Define a single LoadingState enum with idle, loading, loaded, and failed cases. This prevents contradictory View state and enforces clarity in your ViewModel logic.
  • Use ContentUnavailableView to render error and empty states with system-consistent styling. Avoid custom error screens unless your design requires them.
  • Always provide a retry action that resets the state to .loading. Confirm the user's tap with clear feedback, even if the request completes instantly.
  • Implement a minimum loading duration (200ms typical) to hide fast network calls and eliminate flicker. The trade-off is worth the smoother perceived experience.
  • Capture and display error details. Let users understand what went wrong so they can decide whether to retry, wait, or reach support.
  • Test edge cases: very fast responses, timeouts, server errors, network disconnection, and retry scenarios. Production rarely matches your local testing environment.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles