Build a Robust API Client in SwiftUI with async/await

Published: Friday, February 20, 2026 Tags: 3
Authors

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 URLSession and mocked APIClientProtocol

Related Articles

Ranked by shared tags and recency of publication date.

Recent post

Hello World.

Hello World. A short introduction to this site and what I will share here.

Feb 23, 2023