iOS with SwiftUI · Navigation, Networking, Persistence and Concurrency
Networking with URLSession, async/await and Codable
Fetch JSON from a REST API with URLSession and async/await, decode it with Codable, check HTTP status codes, map errors to messages, and load data with the .task modifier.
What you'll build
The repositories screen's data layer: an API client that fetches and decodes JSON, validates the HTTP response, maps errors to messages users understand, and a view model that loads when the screen appears and cancels when it disappears.
The idea
URLSession.shared.data(from:)is anasyncfunction:let (data, response) = try await .... It throws for transport errors (offline, timeout), but not for HTTP errors, so check the status code yourself.JSONDecoderwithCodabletypes turns the JSON into Swift values;.convertFromSnakeCasemapsstargazers_counttostargazersCount.- Put networking behind a protocol (
RepoService) so view models can be tested with a fake. - In SwiftUI, start loading with
.task { await model.load() }: it runs when the view appears and is cancelled automatically when it disappears. - iOS requires HTTPS by default (App Transport Security).
The code
struct Repo: Codable, Identifiable {
let id: Int
let fullName: String
let stargazersCount: Int
}
struct SearchResponse: Codable { let items: [Repo] }
enum APIError: Error { case badStatus(Int), offline, decoding }
protocol RepoService: Sendable {
func topRepos() async throws(APIError) -> [Repo]
}
struct GitHubService: RepoService {
func topRepos() async throws(APIError) -> [Repo] {
let url = URL(string: "https://api.github.com/search/repositories?q=language:swift&per_page=20")!
let data: Data, response: URLResponse
do {
(data, response) = try await URLSession.shared.data(from: url)
} catch {
throw .offline
}
guard let http = response as? HTTPURLResponse, (200..<300).contains(http.statusCode) else {
throw .badStatus((response as? HTTPURLResponse)?.statusCode ?? 0)
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do { return try decoder.decode(SearchResponse.self, from: data).items } catch { throw .decoding }
}
}
@MainActor @Observable
final class RepoListModel {
enum State { case loading, loaded([Repo]), failed(String) }
private(set) var state = State.loading
private let service: any RepoService
init(service: any RepoService) { self.service = service }
func load() async {
state = .loading
do { state = .loaded(try await service.topRepos()) }
catch { state = .failed(error.userMessage) }
}
}
struct RepoListView: View {
@State var model: RepoListModel
var body: some View {
Group {
switch model.state {
case .loading: ProgressView()
case .loaded(let repos): List(repos) { Text($0.fullName) }
case .failed(let message):
ContentUnavailableView("Can't load", systemImage: "wifi.slash", description: Text(message))
}
}
.task { await model.load() }
.refreshable { await model.load() }
}
}
Try the logic
Decoding a response, checking status codes and mapping errors, with a fake transport instead of the network:
import Foundation
struct Repo: Codable {
let id: Int
let fullName: String
let stargazersCount: Int
}
struct SearchResponse: Codable { let items: [Repo] }
enum APIError: Error {
case badStatus(Int), offline, decoding
var userMessage: String {
switch self {
case .offline: "You're offline. Pull to refresh when you're connected."
case .badStatus(let code) where code >= 500: "The server is having trouble. Try again soon."
case .badStatus(404): "Not found."
case .badStatus: "Something went wrong."
case .decoding: "The app couldn't read the server's response."
}
}
}
func decodeRepos(status: Int, body: String) throws(APIError) -> [Repo] {
guard (200..<300).contains(status) else { throw .badStatus(status) }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
do { return try decoder.decode(SearchResponse.self, from: Data(body.utf8)).items } catch { throw .decoding }
}
let ok = #"{"total_count": 2, "items": [{"id": 1, "full_name": "apple/swift", "stargazers_count": 69000, "private": false}]}"#
for (status, body) in [(200, ok), (503, ""), (404, ""), (200, #"{"items": "nope"}"#)] {
do {
let repos = try decodeRepos(status: status, body: body)
print("\(status): \(repos.map { "\($0.fullName) (\($0.stargazersCount) stars)" })")
} catch {
print("\(status): \(error.userMessage)")
}
}
print(APIError.offline.userMessage)
Output
200: ["apple/swift (69000 stars)"] 503: The server is having trouble. Try again soon. 404: Not found. 200: The app couldn't read the server's response. You're offline. Pull to refresh when you're connected.
Run this on your computer — see “Get Started” for setup.
Common mistakes
- Not checking the HTTP status.
data(from:)succeeds for a 500 page; decoding then fails with a confusing error. - Updating UI state off the main actor. Make view models
@MainActorso state changes happen on the main thread. - Starting tasks in
onAppearwithout cancelling them..taskcancels automatically; aTask {}inonAppearkeeps running after the screen is gone. - Showing raw error descriptions. Map errors to messages users understand and log the details.
Key points
URLSessionwith async/await plusCodableis the standard networking stack; no third-party library is needed.- Validate status codes and map errors to typed, user-friendly cases.
- Hide networking behind a protocol for testability.
- Load with
.taskso work is cancelled with the view.
Further reading: Fetching website data into memory, Encoding and decoding custom types.
Practice questions
Does URLSession's data(from:) throw for a 404 or 500 response?
Answer
No. It throws only for transport-level failures such as no connection, timeouts or TLS errors. HTTP error status codes are successful transfers, so you must cast the response to HTTPURLResponse and check statusCode yourself.
Why load data with the .task modifier instead of onAppear?
Answer
.task starts an async task tied to the view's lifetime and cancels it automatically when the view disappears (or when its id changes), so requests for screens the user has left don't keep running or update state later.
Open in the app to mark lessons complete, take notes and track your progress.