EduFlow

Section 4 · Shipping a Real App

Networking with URLSession and Codable

17 min3 min readPut in order

Lesson at a glance

Fetch JSON with URLSession’s async API, check the HTTP status, decode it into Codable structs, and show loading, loaded and failed states in SwiftUI.

  1. 1Mirror the JSON with Codable
  2. 2Fetch, check, decode
  3. 3Loading states the view can trust

No video here — just the real thing: a written lesson, runnable examples, a quiz and practice.

Pocket Budget records expenses in dollars, but you spend money on holiday in euros and Egyptian pounds. To convert, the app needs today’s exchange rates from a server. This lesson replaces the pretend Task.sleep from the last lesson with a real request: build a URL, fetch the data, check the response, decode the JSON into Swift types and show it. Those steps are the same for nearly every API you will ever call.

The examples use a stand-in endpoint, https://api.example.com/v1/latest?base=USD. Swap in a real exchange-rate provider (most have a free tier) and adjust the struct to match its JSON. The response looks like this:

JSON
{
  "base": "USD",
  "updated_at": "2026-10-02T06:00:00Z",
  "rates": { "EUR": 0.92, "GBP": 0.79, "EGP": 48.31 }
}

Mirror the JSON with Codable

RatesResponse.swift
import Foundation

struct RatesResponse: Decodable {
    let base: String
    let updatedAt: Date
    let rates: [String: Decimal]
}

Decodable asks the compiler to generate the code that reads this type from an external format. Property names must match the JSON keys and the types must fit: a JSON object becomes a dictionary, a number can decode into Decimal, and a missing key or wrong type throws a DecodingError that names the problem. Use Codable (Decodable plus Encodable) when you also send the type back. The struct is made of Sendable parts, so it’s Sendable too and can travel safely from the network code to the main actor.

The JSON says updated_at; Swift style says updatedAt. Rather than renaming properties, configure the decoder, as the client below does.

Fetch, check, decode

RateClient.swift
enum RateError: Error {
    case badStatus(Int)
}

struct RateClient {
    let endpoint = URL(string: "https://api.example.com/v1/latest")!

    func latest(base: String) async throws -> RatesResponse {
        let url = endpoint.appending(queryItems: [URLQueryItem(name: "base", value: base)])

        let (data, response) = try await URLSession.shared.data(from: url)

        let status = (response as? HTTPURLResponse)?.statusCode ?? 0
        guard status == 200 else {
            throw RateError.badStatus(status)
        }

        let decoder = JSONDecoder()
        decoder.keyDecodingStrategy = .convertFromSnakeCase
        decoder.dateDecodingStrategy = .iso8601
        return try decoder.decode(RatesResponse.self, from: data)
    }
}

Walk through it in order. appending(queryItems:) builds ...latest?base=USD and percent-encodes values for you, which is safer than gluing strings together. The force unwrap on URL(string:) is the acceptable kind from the optionals lesson: a literal you wrote, where nil would mean a typo.

URLSession.shared.data(from:) is the async API: it suspends until the whole response has arrived and returns the body bytes plus a URLResponse. It throws for transport failures, such as no connection, a timeout or cancellation, but it does not throw for HTTP errors. That’s what the guard is for.

.convertFromSnakeCase maps updated_at to updatedAt for every key, and .iso8601 reads the timestamp format most APIs use. Then decode either returns a fully formed RatesResponse or throws.

Five steps left to right: build the URL, fetch with data(from:), check the status code, decode JSON into RatesResponse, then set state on the main actor so the view updates. Build URL query items Fetch data(from:) Check status 200 Decode JSONDecoder Show main actor Any step can throw; one catch in the model turns it into the failed state
From URL to pixels

Not every failure deserves the same message, either. A thrown URLError carries a code, so you can tell “offline” (.notConnectedToInternet) apart from “timed out” (.timedOut) and say “You’re offline. Rates will refresh when you reconnect” instead of a generic apology. Users forgive an app that explains itself.

Loading states the view can trust

The enum idea from the enums lesson now pays off. A model on the main actor owns the state, and the view switches over it:

RatesModel.swift
@MainActor
@Observable
final class RatesModel {
    enum LoadState {
        case idle
        case loading
        case loaded(RatesResponse)
        case failed(String)
    }

    var state: LoadState = .idle
    private let client = RateClient()

    func load() async {
        state = .loading
        do {
            let response = try await client.latest(base: "USD")
            state = .loaded(response)
        } catch {
            state = .failed(error.localizedDescription)
        }
    }
}
RatesView.swift
struct RatesView: View {
    @State private var model = RatesModel()

    var body: some View {
        List {
            switch model.state {
            case .idle, .loading:
                ProgressView()
            case .loaded(let response):
                ForEach(response.rates.keys.sorted(), id: \.self) { code in
                    LabeledContent(code, value: response.rates[code] ?? 0,
                                   format: .number.precision(.fractionLength(2...4)))
                }
            case .failed(let message):
                ContentUnavailableView("Couldn't load rates",
                                       systemImage: "wifi.exclamationmark",
                                       description: Text(message))
            }
        }
        .navigationTitle("Exchange Rates")
        .task { await model.load() }
        .refreshable { await model.load() }
    }
}

.task loads once when the screen appears, and .refreshable adds pull-to-refresh with the same function. Because RatesModel is @MainActor, assigning state is always safe for the UI, and Swift 6 checks that for you. Every error path ends in .failed, so the user never stares at a spinner that will never stop. Dictionary keys have no order, so the view sorts them for a stable list.

iOS also requires HTTPS by default through App Transport Security, so a plain http:// URL fails unless you add an exception. Use HTTPS and you’ll never need one.

Pocket Budget can now talk to the outside world. It still forgets its own expenses when you quit, so next you’ll save them on the device with SwiftData.

Key takeaways

  • try await URLSession.shared.data(from: url) returns the body and the response; it throws for network failures but not for HTTP errors like 404 or 500.
  • Always check the HTTPURLResponse status code before decoding, and turn a bad status into your own error.
  • Mirror the JSON with Decodable structs; keyDecodingStrategy and dateDecodingStrategy handle snake_case keys and ISO 8601 dates.
  • Model the screen’s lifecycle as a LoadState enum so loading, data and errors can never be shown at the same time.

Further reading

Check your understanding

3 questions

Question 1 of 3

The server answers a request with HTTP 500 and an HTML error page. What does try await URLSession.shared.data(from: url) do?

Question 2 of 3

The JSON has a key updated_at, and your struct has let updatedAt: Date. Decoding fails with keyNotFound. What’s the smallest fix?

Question 3 of 3

Why does the rates screen use one LoadState enum instead of isLoading, rates and errorMessage properties?

Practice

Put in order

From screen appearance to rates on screen

Put the steps of Pocket Budget’s exchange-rate screen in the order they happen at runtime, from the moment the screen appears to the moment the rates are visible.

Use the arrow buttons to reorder.

  1. 1Build the URL: endpoint.appending(queryItems: [URLQueryItem(name: "base", value: "USD")])
  2. 2The model sets state = .loading, so the view shows a ProgressView
  3. 3let (data, response) = try await URLSession.shared.data(from: url)
  4. 4SwiftUI runs .task { await model.load() } as the view appears
  5. 5try decoder.decode(RatesResponse.self, from: data) with snake_case and ISO 8601 strategies
  6. 6Check (response as? HTTPURLResponse)?.statusCode == 200, otherwise throw RateError.badStatus
  7. 7Assign state = .loaded(response) on the main actor, and SwiftUI redraws the list