How to Make a Network Request in SwiftUI
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
-
Decodable model (lines 4–8).
Postconforms toDecodablesoJSONDecodercan map JSON keys to Swift properties automatically.IdentifiableletsListtrack rows without a customid:parameter. -
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. -
.task modifier (line 59).
.tasklaunches an async context tied to the view's lifetime. UnlikeonAppear, it automatically cancels the in-flight request if the view disappears — no manual cancellation needed. -
Three UI states (lines 37–55). The
Groupswitch covers loading, error, empty, and populated states. iOS 17'sContentUnavailableViewprovides system-consistent empty/error UI without custom layouts. -
Pull-to-refresh (line 60). Adding
.refreshablereuses the sameloadPosts()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
-
App Transport Security (ATS) blocks plain HTTP. All
http://URLs are blocked by default on iOS. Usehttps://everywhere, or add anNSExceptionDomainsentry inInfo.plistonly for local dev servers. -
Forgetting to validate the HTTP status code.
URLSessiondoes not throw on 4xx/5xx responses — it only throws for network-level errors (no connection, timeout). Always checkHTTPURLResponse.statusCodebefore decoding. -
Updating
@Stateoff the main actor. Before Swift 6 strict concurrency, accidentally mutating@Statefrom a background thread causes runtime warnings or crashes. Mark your view or loading function@MainActor(views are implicitly@MainActoralready) to keep mutations safe. -
Ignoring cancellation in
.task. Long-running loops inside.taskshould calltry Task.checkCancellation()periodically so the system can cleanly cancel work when the view disappears.
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.