Bryn Flow

🍎 Build a Weather App with URLSession and Async/Await

Fetch live data from a REST API, decode it with Codable, and handle loading/error/success states cleanly in SwiftUI.

Intermediate ⏱️ ~35 minutes 🧰 Swift, URLSession, async/await, Codable
← iOS Track

What You'll Build

A screen with a city text field. Submitting fetches current weather from a public API, showing a spinner while loading and either the result or an error message.

Prerequisites

Step by Step

1 Model the API response with Codable

// WeatherResponse.swift
struct WeatherResponse: Decodable {
    let name: String
    let main: Main
    let weather: [WeatherDesc]

    struct Main: Decodable {
        let temp: Double
        let feels_like: Double
        let humidity: Int
    }
    struct WeatherDesc: Decodable {
        let description: String
    }
}

2 Write the networking function

// WeatherService.swift
enum WeatherError: Error { case badResponse, badURL }

struct WeatherService {
    static let apiKey = "YOUR_API_KEY"

    static func fetch(city: String) async throws -> WeatherResponse {
        guard var components = URLComponents(string: "https://api.openweathermap.org/data/2.5/weather") else {
            throw WeatherError.badURL
        }
        components.queryItems = [
            URLQueryItem(name: "q", value: city),
            URLQueryItem(name: "appid", value: apiKey),
            URLQueryItem(name: "units", value: "metric")
        ]
        guard let url = components.url else { throw WeatherError.badURL }

        let (data, response) = try await URLSession.shared.data(from: url)
        guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
            throw WeatherError.badResponse
        }
        return try JSONDecoder().decode(WeatherResponse.self, from: data)
    }
}

Never hardcode a real API key in source you commit — read it from an .xcconfig file excluded from version control in a production app.

3 Model UI state explicitly

// WeatherUiState.swift
enum WeatherUiState {
    case idle
    case loading
    case success(WeatherResponse)
    case failure(String)
}

4 Build the view model

// WeatherViewModel.swift
@MainActor
class WeatherViewModel: ObservableObject {
    @Published var state: WeatherUiState = .idle

    func search(city: String) {
        guard !city.isEmpty else { return }
        state = .loading

        Task {
            do {
                let result = try await WeatherService.fetch(city: city)
                state = .success(result)
            } catch {
                state = .failure("Couldn't load weather — check the city name and your connection")
            }
        }
    }
}

@MainActor guarantees state is only ever mutated on the main thread, which SwiftUI requires for triggering view updates safely.

5 Build the SwiftUI screen

// WeatherView.swift
struct WeatherView: View {
    @StateObject private var viewModel = WeatherViewModel()
    @State private var city = ""

    var body: some View {
        VStack(spacing: 20) {
            HStack {
                TextField("City name", text: $city)
                    .textFieldStyle(.roundedBorder)
                Button("Search") { viewModel.search(city: city) }
            }

            switch viewModel.state {
            case .idle:
                Text("Enter a city to see the weather.")
            case .loading:
                ProgressView()
            case .failure(let message):
                Text("⚠️ \(message)").foregroundColor(.red)
            case .success(let data):
                VStack(alignment: .leading, spacing: 6) {
                    Text(data.name).font(.title2).bold()
                    Text("\(data.main.temp, specifier: "%.1f")°C — feels like \(data.main.feels_like, specifier: "%.1f")°C")
                    Text(data.weather.first?.description ?? "")
                    Text("Humidity: \(data.main.humidity)%")
                }
            }
        }
        .padding()
    }
}

The exhaustive switch over the enum means Swift's compiler forces every case to be handled — add a new state later and the build fails until you handle it in the view too.

6 Run it

Set WeatherView() as your root view in the App struct, run, type a city, and tap Search. You should see the spinner briefly, then either weather details or an error — try an invalid city name to confirm the error path renders correctly.

Final Working Code

The five files above — WeatherResponse.swift, WeatherService.swift, WeatherUiState.swift, WeatherViewModel.swift, and WeatherView.swift — form the complete app exactly as written; nothing was trimmed for the walkthrough.

What to Try Next

Want more iOS walkthroughs?

Head back to the iOS track for the full topic list, roadmap, and practice quiz.

Back to iOS Track