Build a Robust API Client in SwiftUI with async/await
- Authors
- Author
- Masaki Hayashi
- twitter @mh_poteto
Introduction
When building iOS apps, networking is often where complexity starts to grow.
This article shows a practical structure for API calls in SwiftUI using async/await:
- A small and testable API client
- Clear error modeling
- ViewModel-driven state handling in SwiftUI
Prerequisites
- Xcode 16+
- Swift 6+
- Basic knowledge of SwiftUI and
ObservableObject
1. Define the Response Model
Start with a Decodable model that matches your API response.
import Foundation
struct TodoItem: Decodable, Identifiable {
let id: Int
let title: String
let completed: Bool
} 2. Define API Errors Explicitly
Use a typed error to keep UI behavior consistent.
import Foundation
enum APIError: LocalizedError {
case invalidURL
case requestFailed(Int)
case decodingFailed
case unknown
var errorDescription: String? {
switch self {
case .invalidURL:
return "The request URL is invalid."
case .requestFailed(let statusCode):
return "Request failed with status code \(statusCode)."
case .decodingFailed:
return "Failed to decode server response."
case .unknown:
return "An unexpected error occurred."
}
}
} 3. Create a Small API Client
Keep the client focused and easy to mock in tests.
import Foundation
protocol APIClientProtocol {
func fetchTodos() async throws -> [TodoItem]
}
struct APIClient: APIClientProtocol {
private let session: URLSession
init(session: URLSession = .shared) {
self.session = session
}
func fetchTodos() async throws -> [TodoItem] {
guard let url = URL(string: "https://jsonplaceholder.typicode.com/todos?_limit=20")
else {
throw APIError.invalidURL
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await session.data(from: url)
} catch {
throw APIError.unknown
}
guard let httpResponse = response as? HTTPURLResponse else {
throw APIError.unknown
}
guard (200...299).contains(httpResponse.statusCode) else {
throw APIError.requestFailed(httpResponse.statusCode)
}
do {
return try JSONDecoder().decode([TodoItem].self, from: data)
} catch {
throw APIError.decodingFailed
}
}
} 4. Build a ViewModel for UI State
Use a single source of truth for loading, success, and error states.
import Foundation
@MainActor
final class TodoListViewModel: ObservableObject {
@Published private(set) var items: [TodoItem] = []
@Published private(set) var isLoading = false
@Published var errorMessage: String?
private let apiClient: APIClientProtocol
init(apiClient: APIClientProtocol = APIClient()) {
self.apiClient = apiClient
}
func load() async {
isLoading = true
errorMessage = nil
defer { isLoading = false }
do {
items = try await apiClient.fetchTodos()
} catch let apiError as APIError {
errorMessage = apiError.localizedDescription
} catch {
errorMessage = APIError.unknown.localizedDescription
}
}
} 5. Connect It to SwiftUI
Use .task so data loads when the screen appears.
import SwiftUI
struct TodoListView: View {
@StateObject private var viewModel = TodoListViewModel()
var body: some View {
NavigationStack {
Group {
if viewModel.isLoading {
ProgressView("Loading...")
} else if let message = viewModel.errorMessage {
VStack(spacing: 12) {
Text(message)
.multilineTextAlignment(.center)
Button("Retry") {
Task { await viewModel.load() }
}
}
.padding()
} else {
List(viewModel.items) { item in
HStack {
Image(systemName: item.completed ? "checkmark.circle.fill" : "circle")
Text(item.title)
}
}
}
}
.navigationTitle("Todos")
.task {
await viewModel.load()
}
}
}
} Summary
- Keep networking code small and explicit
- Model errors so UI can react consistently
- Use a ViewModel to separate API logic from SwiftUI views
- Inject dependencies (
APIClientProtocol) to make testing easier
Next Step
As a follow-up, you can add:
- Request timeout and retry strategy
- Token-based authentication handling
- Unit tests with a mocked
URLSessionand mockedAPIClientProtocol
Related Articles
Ranked by shared tags and recency of publication date.
Recent post
Building a Simple Bulletin Board App with Next.js and ChatGPT
A practical guide for building a lightweight bulletin board app with React, TypeScript, Next.js, and PostgreSQL.
Mar 18, 2023
Recent post
How to Build a Todo App with Next.js
A practical walkthrough for building a Todo app using React, TypeScript, and Next.js.
Mar 17, 2023
Recent post
Hello World.
Hello World. A short introduction to this site and what I will share here.
Feb 23, 2023