SwiftUI Patterns

Building Robust Lists: Loading & Error States

May 7, 2026
5 min read
Featured image for blog post: Building Robust Lists: Loading & Error States

Every list needs to handle loading, success, empty, and error states. Here's the pattern that covers all four.

Follow along with the code: iOS-Practice on GitHub

The Four States

A data-driven list has four possible states:

  1. Loading - Data is being fetched
  2. Success - Data loaded, show the list
  3. Empty - Data loaded but nothing to show
  4. Error - Something went wrong

The Pattern

struct BasicListExerciseView: View {
    @State private var users: [User] = []
    @State private var isLoading = false
    @State private var error: Error?

    var body: some View {
        Group {
            if isLoading {
                ProgressView("Loading users...")
            } else if let error = error {
                ContentUnavailableView {
                    Label("Error", systemImage: "exclamationmark.triangle")
                } description: {
                    Text(error.localizedDescription)
                } actions: {
                    Button("Retry") {
                        Task { await loadUsers() }
                    }
                    .buttonStyle(.borderedProminent)
                }
            } else if users.isEmpty {
                ContentUnavailableView("No Users", systemImage: "person.slash")
            } else {
                List(users) { user in
                    UserRowView(user: user)
                }
            }
        }
        .navigationTitle("Users")
        .task {
            await loadUsers()
        }
    }

    private func loadUsers() async {
        isLoading = true
        error = nil

        do {
            users = try await MockAPIService.shared.fetchUsers()
        } catch {
            self.error = error
        }

        isLoading = false
    }
}

Key Elements

State Variables

@State private var users: [User] = []
@State private var isLoading = false
@State private var error: Error?

Three variables cover all states. The combination determines what to show.

Group for Conditional Content

Group {
    if isLoading { ... }
    else if let error { ... }
    else if users.isEmpty { ... }
    else { ... }
}

Group lets you switch between completely different view hierarchies.

The .task Modifier

.task {
    await loadUsers()
}

Automatically runs when the view appears and cancels when it disappears.

The Row View

Keep row views simple and focused:

struct UserRowView: View {
    let user: User

    var body: some View {
        HStack(spacing: 12) {
            Image(systemName: user.avatarURL)
                .font(.largeTitle)
                .foregroundColor(.accentColor)

            VStack(alignment: .leading, spacing: 4) {
                Text(user.name)
                    .font(.headline)
                Text(user.email)
                    .font(.subheadline)
                    .foregroundColor(.secondary)
                Text(user.company)
                    .font(.caption)
                    .foregroundColor(.blue)
            }
        }
        .padding(.vertical, 4)
    }
}

Loading Function Pattern

private func loadUsers() async {
    isLoading = true
    error = nil           // Clear previous error

    do {
        users = try await api.fetchUsers()
    } catch {
        self.error = error
    }

    isLoading = false     // Always reset, success or failure
}

Always reset isLoading in both success and failure paths.

Interview Tip

When discussing this pattern, mention:

  • Why Group is used (switching view types)
  • Why .task over onAppear (cancellation)
  • How the retry button works (calls the same load function)

Originally published on pixelper.com

© 2026 Christopher Moore / Dead Pixel Studio

Let's work together

Professional discovery, design, and complete technical coverage for your ideas

Get in touch