DEV Community

Sebastien Lato
Sebastien Lato

Posted on

Global Error Handling in SwiftUI

Most SwiftUI apps handle errors wrong:

  • showing alerts directly in views
  • duplicating error logic everywhere
  • leaking async errors into UI
  • showing confusing messages
  • ignoring cancellation
  • racing alerts while navigating

A real app needs one unified error system β€” a global mechanism that:

  • captures all errors
  • categorizes them
  • maps them to user-friendly messages
  • decides when to show alerts or fallback UI
  • centralizes retry logic
  • integrates with async + DI + lifecycle
  • works offline and online

Here is the production-grade pattern for mastering SwiftUI error handling.


🧱 1. Start With an App-Wide Error Model

Create a simple, typed error domain.

enum AppError: Error, Equatable {
    case network
    case offline
    case unauthorized
    case notFound
    case validation(String)
    case unknown
}
Enter fullscreen mode Exit fullscreen mode

This converts chaotic errors into predictable categories.


🌍 2. A Global ErrorPresenter (the heart of the system)

This object manages:

  • current active error
  • translated message
  • retry handlers
@Observable
class ErrorPresenter {
    var current: PresentedError?

    struct PresentedError: Identifiable {
        let id = UUID()
        let message: String
        let retry: (() -> Void)?
    }

    func present(_ error: AppError, retry: (() -> Void)? = nil) {
        current = .init(message: error.userMessage, retry: retry)
    }

    func dismiss() {
        current = nil
    }
}
Enter fullscreen mode Exit fullscreen mode

Inject this at the app root:

@main
struct MyApp: App {
    @State var errors = ErrorPresenter()

    var body: some Scene {
        WindowGroup {
            RootView()
                .environment(errors)
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

πŸ’¬ 3. User-Friendly Error Messages

Extend the domain:

extension AppError {
    var userMessage: String {
        switch self {
        case .network:
            return "Network error. Please try again."
        case .offline:
            return "You're offline. Changes will sync later."
        case .unauthorized:
            return "Your session expired. Please log in again."
        case .notFound:
            return "This item is no longer available."
        case .validation(let msg):
            return msg
        case .unknown:
            return "Something went wrong."
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

This ensures ALL errors display consistently.


πŸ”— 4. Capturing Errors in Async Functions

Wrap async work:

func loadProfile() async {
    do {
        profile = try await api.fetchProfile()
    } catch {
        errors.present(map(error))
    }
}
Enter fullscreen mode Exit fullscreen mode

Map raw errors:

func map(_ error: Error) -> AppError {
    if error is URLError { return .network }
    if error is CancellationError { return .unknown } // ignore to avoid spam
    return .unknown
}
Enter fullscreen mode Exit fullscreen mode

🧰 5. Add Retry Logic (Properly)

func loadFeed() async {
    do {
        posts = try await api.fetchPosts()
    } catch {
        errors.present(map(error)) { [weak self] in
            Task { await self?.loadFeed() }
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Now your UI shows:

  • error message
  • retry button
  • optional fallback

All centralized.


πŸͺŸ 6. Display Errors With a Global Alert Overlay

In your root view:

.overlay {
    if let err = errors.current {
        ErrorAlert(message: err.message, retry: err.retry) {
            errors.dismiss()
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

Example alert UI:

struct ErrorAlert: View {
    let message: String
    let retry: (() -> Void)?
    let dismiss: () -> Void

    var body: some View {
        VStack(spacing: 16) {
            Text(message)
                .font(.headline)

            HStack {
                Button("Dismiss", action: dismiss)
                if let retry {
                    Button("Retry", action: retry)
                        .buttonStyle(.borderedProminent)
                }
            }
        }
        .padding()
        .background(.ultraThinMaterial)
        .clipShape(RoundedRectangle(cornerRadius: 20))
        .shadow(radius: 20)
    }
}
Enter fullscreen mode Exit fullscreen mode

No need for .alert() in every view anymore.


βš›οΈ 7. Auto-Recover on App Lifecycle Changes

Automatically retry when coming back online:

.onChange(of: networkMonitor.isOnline) { online in
    if online, let retry = errors.current?.retry {
        retry()
        errors.dismiss()
    }
}
Enter fullscreen mode Exit fullscreen mode

Or refresh when .active:

.onChange(of: scenePhase) { phase in
    if phase == .active {
        retryPendingRequests()
    }
}
Enter fullscreen mode Exit fullscreen mode

Lifecycle + error handling = stability.


πŸ”„ 8. Offline-First Error Rules

A real offline-first app should:

βœ” Not show errors for offline failures

Instead:

  • load from cache
  • queue mutations
  • show subtle offline UI state

βœ” Retry automatically when back online
βœ” Never block UI due to network errors
βœ” Never show alerts inside lists/tabs

Offline-first + global errors = production quality.


πŸ§ͺ 9. Testing the Error System

Test that errors are mapped correctly:

func test_network_error_mapping() {
    let presenter = ErrorPresenter()
    presenter.present(.network)
    XCTAssertEqual(presenter.current?.message, "Network error. Please try again.")
}
Enter fullscreen mode Exit fullscreen mode

Test retry behavior:

func test_retry_handler_runs() {
    var retried = false

    let presenter = ErrorPresenter()
    presenter.present(.unknown) {
        retried = true
    }

    presenter.current?.retry?()
    XCTAssertTrue(retried)
}
Enter fullscreen mode Exit fullscreen mode

Test dismissal:

func test_dismiss_clears_error() {
    let presenter = ErrorPresenter()
    presenter.present(.unknown)
    presenter.dismiss()
    XCTAssertNil(presenter.current)
}
Enter fullscreen mode Exit fullscreen mode

Fully testable. Fully predictable.


πŸš€ Final Thoughts

A unified error system transforms your SwiftUI app:

  • from chaotic β†’ predictable
  • from duplicated logic β†’ centralized
  • from UI alerts everywhere β†’ one controlled overlay
  • from user frustration β†’ graceful recovery

Real apps require real error handling.

This global system integrates perfectly with:

  • async/await
  • offline-first architecture
  • dependency injection
  • app lifecycle
  • global state
  • navigation and deep links

Top comments (0)