SwiftUI Networking — Async Data Fetching with ObservableObject
In this tutorial, you will learn about SwiftUI Networking. We cover key concepts, practical examples, and best practices to help you master this topic.
SwiftUI networking combines URLSession's async/await API with ObservableObject view models to fetch, display, and update data from REST APIs while managing loading states and errors declaratively.
What You'll Learn
- URLSession async/await with SwiftUI
- ObservableObject view models for network data
- Loading, error, and empty state management
- AsyncImage for remote image loading
- Pull-to-refresh with .refreshable
- Caching strategies for network responses
Why It Matters
Most modern apps fetch data from the internet. SwiftUI's reactive data flow makes it natural to connect network responses to UI updates — the view model fetches data, publishes changes, and the UI re-renders automatically. This pattern is used in every production SwiftUI app.
Real-World Use
A news reader app fetches articles from a REST API using async/await in a NewsViewModel. The view displays a ProgressView while loading, a List of articles when data arrives, and an error message with a retry button on failure. Pull-to-refresh triggers a new fetch. Tapping an article pushes a detail view.
Learning Path
flowchart LR A[SwiftUI Animations
Lesson 29] --> B[SwiftUI Networking
You are here] B --> C[Testing
Lesson 31] B --> D[Swift Package Manager
Lesson 32] style B fill:#f90,color:#fff
View Model Pattern for Networking
The standard pattern is a view model class that manages the async fetch and publishes state changes.
import SwiftUI
struct Post: Decodable, Identifiable {
let id: Int
let title: String
let body: String
}
enum LoadingState {
case idle
case loading
case loaded
case error(String)
}
@MainActor
class PostsViewModel: ObservableObject {
@Published var posts: [Post] = []
@Published var state: LoadingState = .idle
@Published var selectedPost: Post?
func fetchPosts() async {
state = .loading
do {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
let (data, response) = try await URLSession.shared.data(from: url)
guard let httpResponse = response as? HTTPURLResponse,
(200...299).contains(httpResponse.statusCode) else {
throw URLError(.badServerResponse)
}
posts = try JSONDecoder().decode([Post].self, from: data)
state = .loaded
} catch {
state = .error(error.localizedDescription)
print("Fetch failed: \(error)")
}
}
}
struct PostsListView: View {
@StateObject private var viewModel = PostsViewModel()
var body: some View {
NavigationStack {
Group {
switch viewModel.state {
case .idle, .loading:
ProgressView("Loading posts...")
.progressViewStyle(.circular)
case .loaded:
List(viewModel.posts) { post in
VStack(alignment: .leading, spacing: 4) {
Text(post.title)
.font(.headline)
Text(post.body)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
.padding(.vertical, 4)
}
case .error(let message):
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle")
.font(.largeTitle)
.foregroundColor(.orange)
Text("Something went wrong")
.font(.title2)
Text(message)
.font(.body)
.foregroundColor(.secondary)
.multilineTextAlignment(.center)
Button("Try Again") {
Task { await viewModel.fetchPosts() }
}
.buttonStyle(.borderedProminent)
}
.padding()
}
}
.navigationTitle("Posts")
.task {
await viewModel.fetchPosts()
}
}
}
}
The .task modifier automatically starts the async fetch when the view appears and cancels it when the view disappears. This is the safest way to launch async work from a view.
Pull-to-Refresh
Add .refreshable to enable pull-to-refresh.
struct RefreshablePostsView: View {
@StateObject private var viewModel = PostsViewModel()
@State private var lastRefreshed = Date()
var body: some View {
NavigationStack {
List(viewModel.posts) { post in
VStack(alignment: .leading, spacing: 4) {
Text(post.title)
.font(.headline)
Text(post.body)
.font(.subheadline)
.foregroundColor(.secondary)
.lineLimit(2)
}
}
.navigationTitle("Posts")
.refreshable {
await viewModel.fetchPosts()
lastRefreshed = Date()
}
.overlay(alignment: .bottom) {
Text("Last updated: \(lastRefreshed.formatted(date: .omitted, time: .standard))")
.font(.caption)
.foregroundColor(.secondary)
.padding(.bottom, 4)
}
.task {
await viewModel.fetchPosts()
}
}
}
}
Search with Network Call
Combine a search field with debounced network requests.
import SwiftUI
import Combine
struct SearchResult: Decodable, Identifiable {
let id: Int
let login: String
let avatarUrl: String
let htmlUrl: String
enum CodingKeys: String, CodingKey {
case id, login
case avatarUrl = "avatar_url"
case htmlUrl = "html_url"
}
}
@MainActor
class SearchViewModel: ObservableObject {
@Published var query = ""
@Published var results: [SearchResult] = []
@Published var isSearching = false
@Published var errorMessage: String?
private var searchTask: Task<Void, Never>?
func search() async {
searchTask?.cancel()
guard !query.trimmingCharacters(in: .whitespaces).isEmpty else {
results = []
return
}
isSearching = true
errorMessage = nil
searchTask = Task {
do {
try await Task.sleep(nanoseconds: 300_000_000)
try Task.checkCancellation()
let urlString = "https://api.github.com/search/users?q=\(query)"
let url = URL(string: urlString)!
var request = URLRequest(url: url)
request.setValue("application/vnd.github.v3+json", forHTTPHeaderField: "Accept")
let (data, _) = try await URLSession.shared.data(for: request)
let response = try JSONDecoder().decode(GitHubSearchResponse.self, from: data)
results = response.items
isSearching = false
} catch {
if !Task.isCancelled {
errorMessage = error.localizedDescription
isSearching = false
}
}
}
await searchTask?.value
}
}
struct GitHubSearchResponse: Decodable {
let items: [SearchResult]
}
struct SearchableNetworkView: View {
@StateObject private var viewModel = SearchViewModel()
var body: some View {
NavigationStack {
List(viewModel.results) { user in
HStack {
AsyncImage(url: URL(string: user.avatarUrl)) { phase in
if let image = phase.image {
image.resizable().frame(width: 44, height: 44).clipShape(Circle())
} else {
Circle().fill(.gray).frame(width: 44, height: 44)
}
}
VStack(alignment: .leading) {
Text(user.login).fontWeight(.bold)
Text(user.htmlUrl).font(.caption).foregroundColor(.secondary)
}
}
}
.overlay {
if viewModel.isSearching {
ProgressView()
}
}
.navigationTitle("GitHub Search")
.searchable(text: $viewModel.query, prompt: "Search GitHub users...")
.onSubmit(of: .search) {
Task { await viewModel.search() }
}
.onChange(of: viewModel.query) { _, _ in
Task { await viewModel.search() }
}
}
}
}
Loading Images with AsyncImage
SwiftUI's built-in AsyncImage loads remote images with placeholder and error states.
import SwiftUI
struct AsyncImageView: View {
let imageURL: String
var body: some View {
AsyncImage(url: URL(string: imageURL)) { phase in
switch phase {
case .empty:
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.overlay {
ProgressView()
}
case .success(let image):
image
.resizable()
.aspectRatio(contentMode: .fill)
case .failure:
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.overlay {
Image(systemName: "photo")
.font(.largeTitle)
.foregroundColor(.gray)
}
@unknown default:
EmptyView()
}
}
}
}
struct ImageGalleryView: View {
let imageURLs = [
"https://picsum.photos/id/1/400/300",
"https://picsum.photos/id/10/400/300",
"https://picsum.photos/id/100/400/300",
"https://picsum.photos/id/1000/400/300"
]
var body: some View {
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 8) {
ForEach(imageURLs, id: \.self) { url in
AsyncImageView(imageURL: url)
.frame(height: 150)
.cornerRadius(8)
}
}
.padding()
}
}
POST Request with Form
Send data to an API and handle the response.
import SwiftUI
struct CreatePostView: View {
@State private var title = ""
@State private var body = ""
@State private var isSubmitting = false
@State private var result: String?
var body: some View {
NavigationStack {
Form {
Section("New Post") {
TextField("Title", text: $title)
TextField("Body", text: $body, axis: .vertical)
.lineLimit(5)
}
Section {
Button("Submit") {
Task { await submitPost() }
}
.disabled(title.isEmpty || body.isEmpty || isSubmitting)
if isSubmitting {
HStack {
ProgressView()
Text("Submitting...")
}
}
if let result = result {
Text(result)
.foregroundColor(.green)
}
}
}
.navigationTitle("Create Post")
}
}
func submitPost() async {
isSubmitting = true
result = nil
do {
let url = URL(string: "https://jsonplaceholder.typicode.com/posts")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
let newPost = ["title": title, "body": body, "userId": 1]
request.httpBody = try JSONSerialization.data(withJSONObject: newPost)
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse,
httpResponse.statusCode == 201 else {
throw URLError(.badServerResponse)
}
let json = try JSONSerialization.jsonObject(with: data) as? [String: Any]
let postID = json?["id"] as? Int ?? 0
result = "Post created with ID: \(postID)"
title = ""
body = ""
} catch {
result = "Error: \(error.localizedDescription)"
}
isSubmitting = false
}
}
Caching Strategies
Simple in-memory cache for network responses.
import SwiftUI
actor ImageCache {
static let shared = ImageCache()
private var cache: [URL: Data] = [:]
private let maxSize = 50
func get(_ url: URL) -> Data? {
return cache[url]
}
func set(_ url: URL, data: Data) {
if cache.count >= maxSize {
cache.removeFirst()
}
cache[url] = data
}
}
struct CachedAsyncImage: View {
let url: URL
@State private var imageData: Data?
var body: some View {
if let data = imageData, let uiImage = UIImage(data: data) {
Image(uiImage: uiImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
RoundedRectangle(cornerRadius: 8)
.fill(.gray.opacity(0.3))
.overlay { ProgressView() }
.task {
await loadImage()
}
}
}
private func loadImage() async {
if let cached = await ImageCache.shared.get(url) {
imageData = cached
return
}
do {
let (data, _) = try await URLSession.shared.data(from: url)
await ImageCache.shared.set(url, data: data)
imageData = data
} catch {
print("Image load failed: \(error)")
}
}
}
Common Mistakes
Not using @MainActor for view models: Network callbacks may complete on background threads. Mark view model methods with
@MainActoror use.receive(on: DispatchQueue.main).Forgetting to cancel previous requests: When the user types in a search field, cancel the previous search task before starting a new one to avoid stale results.
Not handling all loading states: Always handle idle, loading, loaded, and error states. Missing the error state leaves users stuck on an indefinite loading indicator.
Using .task without cancellation: The
.taskmodifier cancels automatically when the view disappears. For manual task management, store the Task reference.Blocking the main thread with JSON Parsing: JSON parsing is fast for small payloads, but for large datasets, use
JSONDecoderon a background thread.
Practice Questions
- Why should networking view models be marked with @MainActor?
- How does the
.taskmodifier handle view lifecycle? - What is the advantage of async/await over completion handlers in SwiftUI?
- How do you cancel a network request when the view disappears?
- Challenge: Build a Hacker News reader app that fetches the top stories from https://hacker-news.firebaseio.com/v0/topstories.json, then fetches each story's details. Display the list with loading states, pull-to-refresh, and a detail view. Cache the fetched stories in memory.
Mini Project
Create a Weather Dashboard with:
- A view model that fetches weather data from
api.open-meteo.com(free, no API key needed) - A search field for city names using a geocoding API
- Display current temperature, conditions, and 5-day forecast
- AsyncImage for weather condition icons
- Pull-to-refresh
- Error state with retry
- Caching of last successful response
FAQ
What's Next
After mastering networking, learn testing patterns with Testing, or manage dependencies with Swift Package Manager.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro