Now we wire up the API client into SwiftUI by driving an episode list from an @Observable model with an explicit idle/loading/loaded/error state enum. We'll cover loading data in a .task modifier, handling cancellation with Task.checkCancellation, presenting failures with ContentUnavailableView and a retry action, and building an episode row with AsyncImage phases plus bundled sample JSON for fast previews.
Note: in this episode I utilized Development Assets, but forgot to add this to the Xcode project settings. The bundled source code has this fixed. Links SwiftUI Observable View.task(priority:_:) AsyncImage AsyncImagePhase ContentUnavailableView ProgressView Task.checkCancellation() JSONDecoder PreviewTrait.sizeThatFitsLayout Code @MainActor @Observable final class EpisodeListModel { enum State: Hashable { case idle case loading case loaded([API.V2.Episode]) case error(String) } private(set) var state: State = .idle private let api = APIClient() func load() async { guard state != .loading else { return } state = .loading do { let episodes = try await api.fetchEpisodePage() try Task.checkCancellation() state = .loaded(episodes.data) } catch is CancellationError { state = .idle } catch { state = .error(error.localizedDescription) } } func retry() async { state = .idle await load() } } struct EpisodeListView: View { @State private var model = EpisodeListModel() var body: some View { ZStack { switch model.state { case .idle, .loading: ProgressView { Text("Loading episodes") } case .loaded(let episodes): EpisodeList(episodes: episodes) case .error(let message): ContentUnavailableView { Label("Error loading episodes", systemImage: "wifi.exclamationmark") } description: { Text(message) } actions: { Button { Task { await model.retry() } } label: { Text("Retry") } } } } .navigationTitle("NSScreencast") .task { await model.load() } } } struct EpisodeRow: View { let episode: API.V2.Episode var body: some View { HStack { thumbnail VStack(alignment: .leading) { Text("\(episode.episodeNumber)") .font(.caption) .bold() .foregroundStyle(.secondary) Text(episode.title) .font(.headline) .lineLimit(2) Text(episode.publishedAt, format: .dateTime.month().day().year()) .font(.caption) .foregroundStyle(.secondary) } } } private var thumbnail: some View { AsyncImage(url: episode.artworkURLs.small) { phase in switch phase { case .empty: ProgressView() case .success(let image): image.resizable() case .failure: Image(systemName: "film") @unknown default: EmptyView() } } .aspectRatio(contentMode: .fill) .frame(width: 112, height: 68) .clipShape(RoundedRectangle(cornerRadius: 8)) } } extension API.V2.Episode { static var sample: Self { let url = Bundle.main.url(forResource: "sample-episode", withExtension: "json")! let data = try! Data(contentsOf: url) return try! APIClient.decoder.decode(API.V2.Episode.self, from: data) } }