```html SwiftUI: How to Make a Network Request (iOS 17+, 2026)

How to Make a Network Request in SwiftUI

iOS 17+ Xcode 16+ Beginner APIs: URLSession Updated: May 11, 2026
TL;DR

Use URLSession.shared.data(from:) with Swift concurrency inside a .task modifier, then decode the result with JSONDecoder and store it in @State. No third-party libraries needed.

struct Post: Decodable, Identifiable { let id: Int; let title: String }

struct PostListView: View {
    @State private var posts: [Post] = []

    var body: some View {
        List(posts) { Text($0.title) }
            .task {
                let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
                let (data, _) = try! await URLSession.shared.data(from: url)
                posts = try! JSONDecoder().decode([Post].self, from: data)
            }
    }
}

Full implementation

The production-ready version adds proper loading, error, and empty states using iOS 17's ContentUnavailableView, a @State private var isLoading flag, and structured error handling via do/catch. The HTTP status code is also validated before decoding, protecting against silent 4xx/5xx failures that still return data.

import SwiftUI

// MARK: - Model

struct Post: Decodable, Identifiable {
    let id: Int
    let title: String
    let body: String
}

// MARK: - Service

struct PostService {
    static let shared = PostService()
    private let decoder = JSONDecoder()

    func fetchPosts() async throws -> [Post] {
        let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
        let (data, response) = try await URLSession.shared.data(from: url)

        guard let http = response as? HTTPURLResponse,
              (200...299).contains(http.statusCode) else {
            throw URLError(.badServerResponse)
        }

        return try decoder.decode([Post].self, from: data)
    }
}

// MARK: - View

struct PostListView: View {
    @State private var posts: [Post] = []
    @State private var isLoading = false
    @State private var errorMessage: String?

    var body: some View {
        NavigationStack {
            Group {
                if isLoading {
                    ProgressView("Loading posts…")
                        .frame(maxWidth: .infinity, maxHeight: .infinity)
                } else if let error = errorMessage {
                    ContentUnavailableView(
                        "Could not load posts",
                        systemImage: "wifi.exclamationmark",
                        description: Text(error)
                    )
                } else if posts.isEmpty {
                    ContentUnavailableView(
                        "No Posts",
                        systemImage: "tray"
                    )
                } else {
                    List(posts) { post in
                        VStack(alignment: .leading, spacing: 4) {
                            Text(post.title)
                                .font(.headline)
                                .accessibilityLabel("Post title: \(post.title)")
                            Text(post.body)
                                .font(.caption)
                                .foregroundStyle(.secondary)
                                .lineLimit(2)
                        }
                        .padding(.vertical, 4)
                    }
                    .listStyle(.plain)
                }
            }
            .navigationTitle("Posts")
            .task { await loadPosts() }
            .refreshable { await loadPosts() }
        }
    }

    // MARK: - Data loading

    private func loadPosts() async {
        isLoading = true
        errorMessage = nil
        defer { isLoading = false }

        do {
            posts = try await PostService.shared.fetchPosts()
        } catch {
            errorMessage = error.localizedDescription
        }
    }
}

// MARK: - Preview

#Preview {
    PostListView()
}

How it works

  1. Decodable model (lines 4–8). Post conforms to Decodable so JSONDecoder can map JSON keys to Swift properties automatically. Identifiable lets List track rows without a custom id: parameter.
  2. HTTP status validation (lines 18–21). Even a 404 or 500 response can carry a body — checking (200...299).contains(http.statusCode) before decoding prevents silently parsing an error payload as valid data.
  3. .task modifier (line 59). .task launches an async context tied to the view's lifetime. Unlike onAppear, it automatically cancels the in-flight request if the view disappears — no manual cancellation needed.
  4. Three UI states (lines 37–55). The Group switch covers loading, error, empty, and populated states. iOS 17's ContentUnavailableView provides system-consistent empty/error UI without custom layouts.
  5. Pull-to-refresh (line 60). Adding .refreshable reuses the same loadPosts() function, giving users a native refresh gesture for free.

Variants

POST request with a JSON body

struct NewPost: Encodable {
    let title: String
    let body: String
    let userId: Int
}

func createPost(_ post: NewPost) async throws -> Post {
    let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
    var request = URLRequest(url: url)
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(post)

    let (data, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse,
          (200...299).contains(http.statusCode) else {
        throw URLError(.badServerResponse)
    }
    return try JSONDecoder().decode(Post.self, from: data)
}

Adding Authorization headers

Attach a Bearer token by setting it on the URLRequest before sending. Pair this with Keychain storage (see How to implement Keychain storage) to persist the token securely between app launches rather than keeping it in memory or UserDefaults.

var request = URLRequest(url: url)
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")

Common pitfalls

Prompt this with Claude Code

When using Soarias or Claude Code directly to implement this:

Implement a network request layer in SwiftUI for iOS 17+.
Use URLSession with async/await (no Combine).
Decode JSON responses with JSONDecoder into Decodable models.
Show loading, error, and empty states using ContentUnavailableView.
Add pull-to-refresh with .refreshable.
Make it accessible (VoiceOver labels on list rows).
Add a #Preview with realistic sample data.

Drop this prompt into the Soarias Build phase to scaffold a complete networking layer — including the service struct, view, and state machine — before wiring up your real API endpoints.

Related

FAQ

Does this work on iOS 16?

URLSession.shared.data(from:) with async/await is available from iOS 15+, so the networking core works on iOS 16. However, ContentUnavailableView requires iOS 17. On iOS 16, replace it with a custom VStack showing an image and text instead.

Should I use Combine or async/await for networking in SwiftUI?

Prefer async/await. Apple's own SwiftUI tutorials use .task and URLSession async APIs going forward. Combine is still useful when you need to chain multiple reactive streams (e.g., debouncing a search field), but for simple fetch-and-display it adds unnecessary complexity.

What is the UIKit equivalent?

In UIKit you'd call URLSession.shared.dataTask(with:completionHandler:) and dispatch UI updates back to the main queue with DispatchQueue.main.async. The SwiftUI approach using .task is cleaner: Swift concurrency handles the actor hop for you, and task cancellation is wired to the view lifecycle automatically.

Last reviewed: 2026-05-11 by the Soarias team.

```