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:
- Loading - Data is being fetched
- Success - Data loaded, show the list
- Empty - Data loaded but nothing to show
- 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
Groupis used (switching view types) - Why
.taskoveronAppear(cancellation) - How the retry button works (calls the same load function)