Skip to content

SwiftUI Networking — Async Data Fetching with ObservableObject

DodaTech Updated 2026-06-28 8 min read

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

  1. Not using @MainActor for view models: Network callbacks may complete on background threads. Mark view model methods with @MainActor or use .receive(on: DispatchQueue.main).

  2. 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.

  3. 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.

  4. Using .task without cancellation: The .task modifier cancels automatically when the view disappears. For manual task management, store the Task reference.

  5. Blocking the main thread with JSON Parsing: JSON parsing is fast for small payloads, but for large datasets, use JSONDecoder on a background thread.

Practice Questions

  1. Why should networking view models be marked with @MainActor?
  2. How does the .task modifier handle view lifecycle?
  3. What is the advantage of async/await over completion handlers in SwiftUI?
  4. How do you cancel a network request when the view disappears?
  5. 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

Should I use URLSession or a third-party networking library?

URLSession with async/await is sufficient for most apps. Libraries like Alamofire add convenience features but also add dependencies and potential security risks.

How do I handle authentication tokens?

Store tokens in the Keychain via a service class. Add them to URLRequest headers in a central networking layer rather than in each view model.

How do I handle slow networks and timeouts?

Set timeoutIntervalForRequest on URLSessionConfiguration to 30 seconds. Show a progress indicator and allow users to cancel long requests.

Can I use Combine instead of async/await?

Yes. URLSession.dataTaskPublisher works with Combine. However, async/await is simpler for one-shot fetches and integrates well with SwiftUI's .task modifier.

What is the best way to cache network responses?

Use URLCache for HTTP-level caching, an in-memory dictionary for small data, or a disk cache (FileManager) for larger assets like images.

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