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
}
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
}
}
Inject this at the app root:
@main
struct MyApp: App {
@State var errors = ErrorPresenter()
var body: some Scene {
WindowGroup {
RootView()
.environment(errors)
}
}
}
π¬ 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."
}
}
}
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))
}
}
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
}
π§° 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() }
}
}
}
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()
}
}
}
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)
}
}
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()
}
}
Or refresh when .active:
.onChange(of: scenePhase) { phase in
if phase == .active {
retryPendingRequests()
}
}
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.")
}
Test retry behavior:
func test_retry_handler_runs() {
var retried = false
let presenter = ErrorPresenter()
presenter.present(.unknown) {
retried = true
}
presenter.current?.retry?()
XCTAssertTrue(retried)
}
Test dismissal:
func test_dismiss_clears_error() {
let presenter = ErrorPresenter()
presenter.present(.unknown)
presenter.dismiss()
XCTAssertNil(presenter.current)
}
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)