Introduction

Welcome to Chapter 21! After exploring many fundamental and advanced Swift concepts, it’s time to bring them together into a tangible project. In this chapter, we’ll embark on a mini-project: building a simple, data-driven iOS application using Swift and SwiftUI. This project will solidify your understanding of data modeling, networking with modern Swift concurrency (async/await), UI development with SwiftUI, and robust error handling.

Building apps that interact with external data sources is a cornerstone of modern software development. Almost every interesting application fetches information from a server, whether it’s social media feeds, weather updates, or product catalogs. By the end of this chapter, you’ll have a functional app that fetches data from a public API and displays it beautifully, giving you a strong foundation for building more complex, real-world iOS applications.

Before we dive in, ensure you’re comfortable with:

  • SwiftUI basics: Views, State, Bindings (from previous chapters).
  • Structs and Enums: For data modeling and error types.
  • Optionals: Handling potential absence of values.
  • Error Handling: do-catch blocks and throws.
  • Concurrency with async/await: Understanding tasks and asynchronous operations.

Ready to build something cool? Let’s get started!

Core Concepts: The Pillars of a Data-Driven App

Building an app that talks to a server involves several key stages. Let’s break down the core concepts we’ll be applying.

1. Data Modeling: Structuring Your Information

When you receive data from an API, it usually comes in a format like JSON (JavaScript Object Notation). To work with this data in Swift, you need to define Swift types (usually structs) that mirror the structure of the JSON.

Why Decodable? Swift’s Codable protocol (which combines Encodable and Decodable) is a powerful feature that makes converting between JSON and Swift types almost magical. By conforming your struct to Decodable, you tell Swift how to automatically parse incoming JSON data into instances of your type. This saves you from manually parsing each JSON field, reducing boilerplate and potential errors.

For example, if an API returns a list of “Todo” items like this:

[
  {
    "userId": 1,
    "id": 1,
    "title": "delectus aut autem",
    "completed": false
  }
]

Your Swift struct would look very similar, conforming to Decodable.

2. Networking with URLSession and async/await

URLSession is the foundation for all network requests in Apple’s ecosystem. It provides the API for downloading content from URLs, uploading data, and more.

The Power of async/await In earlier Swift versions, networking often involved completion handlers, leading to what’s known as “callback hell” for complex asynchronous flows. Modern Swift, with its structured concurrency features (async/await), transforms this. You can now write asynchronous code that looks and feels like synchronous code, making it much easier to read, write, and debug.

When you make a network request using async/await, the function will await the response without blocking the main thread (which would freeze your UI). Once the data arrives, the execution seamlessly resumes. This is crucial for maintaining a responsive user interface.

3. Displaying Data with SwiftUI’s List and ForEach

SwiftUI provides powerful views for displaying collections of data.

  • List: Ideal for presenting rows of data, often with automatic scrolling and styling that matches platform conventions.
  • ForEach: A view that iterates over a collection of data and creates a view for each element. It’s often used inside List or other container views.

To ensure SwiftUI can efficiently update and reorder items in a List or ForEach, your data model usually needs to conform to the Identifiable protocol. This protocol simply requires a stable id property for each item, allowing SwiftUI to uniquely identify them.

4. Managing Application State

In a data-driven app, the UI needs to react to different states of the data fetching process:

  • Loading: When data is being fetched.
  • Loaded: When data has successfully arrived and is ready to be displayed.
  • Error: When something went wrong during fetching or decoding.

We’ll use SwiftUI’s @State and @Published properties within an ObservableObject (often a ViewModel) to manage these states and automatically update the UI when they change.

Data Flow Diagram

Let’s visualize the journey of data in our app:

flowchart TD User_Interaction[User Interaction] --> SwiftUI_View[SwiftUI View] SwiftUI_View -->|Triggers Data Fetch| ViewModel[ViewModel] ViewModel -->|Calls Network Service| Network_Service[Network Service] Network_Service -->|HTTP Request| Remote_API[Remote API] Remote_API -->|Sends JSON Response| Network_Service Network_Service -->|Decodes JSON to Swift Model| ViewModel ViewModel -->|Updates @Published Properties| SwiftUI_View SwiftUI_View --> Render_Data[Renders Data on Screen] subgraph Error_Handling["Error Handling Path"] Network_Service -.->|On Network Error| ViewModel ViewModel -.->|On Decoding Error| ViewModel ViewModel -.->|Updates Error State| SwiftUI_View SwiftUI_View -.-> Display_Error[Displays Error Message] end

Step-by-Step Implementation: Building Our Todo List App

We’ll build a simple app that fetches a list of “Todo” items from JSONPlaceholder, a free fake API for testing and prototyping.

API Endpoint: https://jsonplaceholder.typicode.com/todos

Prerequisites:

  • Xcode: Make sure you have the latest stable version of Xcode installed. As of 2026-02-26, this would likely be Xcode 17 or 18, supporting Swift 5.10+ or Swift 6 and the latest iOS SDK. You can download it from the Mac App Store or Apple Developer website.
  • Internet Connection: For fetching data.

Step 1: Create a New Xcode Project

  1. Open Xcode.
  2. Choose “Create a new Xcode project”.
  3. Select the “iOS” tab, then “App”, and click “Next”.
  4. Configure your project:
    • Product Name: TodoApp
    • Interface: SwiftUI
    • Language: Swift
    • Storage: None (for this simple app)
    • Include Tests: (Optional, but good practice for real apps)
  5. Click “Next”, choose a location to save your project, and click “Create”.

You now have a basic SwiftUI project!

Step 2: Define Our Data Model (TodoItem)

First, let’s create a Swift struct that matches the structure of the JSON data we expect from the API.

  1. Create a new Swift file: Go to File > New > File... (or Cmd + N), select “Swift File”, and name it TodoItem.swift.

  2. Add the following code to TodoItem.swift:

    import Foundation
    
    // 1. Define the TodoItem struct
    struct TodoItem: Identifiable, Decodable {
        // 2. Properties match the JSON keys
        let userId: Int
        let id: Int
        let title: String
        let completed: Bool
    }
    

    Explanation:

    • import Foundation: Provides essential types like Decodable.
    • struct TodoItem: Identifiable, Decodable: We declare a struct named TodoItem.
      • Identifiable: This protocol is crucial for SwiftUI’s List and ForEach views. It requires a property named id that uniquely identifies each instance. Our TodoItem already has an id property, so conforming to Identifiable is effortless!
      • Decodable: This protocol allows Swift’s JSONDecoder to automatically convert JSON data into TodoItem instances.
    • let userId: Int, let id: Int, let title: String, let completed: Bool: These properties directly correspond to the keys and types in the JSON response. Swift’s JSONDecoder is smart enough to map them automatically if the names match.

Step 3: Create a Data Fetcher Service

It’s good practice to separate networking logic from your UI views. Let’s create a dedicated class to handle fetching our TodoItems.

  1. Create another new Swift file named TodoFetcher.swift.

  2. Add the following code:

    import Foundation
    
    // 1. Define possible errors for our fetching process
    enum NetworkError: Error, LocalizedError {
        case invalidURL
        case requestFailed(Error)
        case decodingFailed(Error)
        case unknown
    
        var errorDescription: String? {
            switch self {
            case .invalidURL:
                return "The URL provided was invalid."
            case .requestFailed(let error):
                return "Network request failed: \(error.localizedDescription)"
            case .decodingFailed(let error):
                return "Failed to decode data: \(error.localizedDescription)"
            case .unknown:
                return "An unknown error occurred."
            }
        }
    }
    
    // 2. Our dedicated class for fetching todos
    class TodoFetcher {
        private let urlString = "https://jsonplaceholder.typicode.com/todos"
    
        // 3. Asynchronous function to fetch todos
        func fetchTodos() async throws -> [TodoItem] {
            // 4. Validate URL
            guard let url = URL(string: urlString) else {
                throw NetworkError.invalidURL
            }
    
            do {
                // 5. Perform the network request using async/await
                // The (data, response) tuple is returned when the request completes
                let (data, response) = try await URLSession.shared.data(from: url)
    
                // 6. Check for a successful HTTP response status code
                guard let httpResponse = response as? HTTPURLResponse,
                      httpResponse.statusCode == 200 else {
                    // If not 200 OK, throw a requestFailed error
                    throw NetworkError.requestFailed(
                        URLError(.badServerResponse, userInfo: [NSLocalizedDescriptionKey: "Server responded with status code \((response as? HTTPURLResponse)?.statusCode ?? -1)"])
                    )
                }
    
                // 7. Decode the data into an array of TodoItem
                let decoder = JSONDecoder()
                return try decoder.decode([TodoItem].self, from: data)
    
            } catch let urlError as URLError {
                // Catch specific URLSession errors
                throw NetworkError.requestFailed(urlError)
            } catch let decodingError as DecodingError {
                // Catch specific decoding errors
                throw NetworkError.decodingFailed(decodingError)
            } catch {
                // Catch any other unexpected errors
                throw NetworkError.unknown
            }
        }
    }
    

    Explanation:

    • enum NetworkError: We define a custom Error enum to categorize potential issues during the network request and data decoding. Conforming to LocalizedError allows us to provide user-friendly error descriptions.
    • class TodoFetcher: This class encapsulates our networking logic.
    • private let urlString: Stores the URL for our API endpoint.
    • func fetchTodos() async throws -> [TodoItem]: This is our core function.
      • async: Marks the function as asynchronous, allowing it to await other asynchronous operations.
      • throws: Indicates that this function can throw errors, which we’ll handle using do-catch.
      • -> [TodoItem]: Specifies that it returns an array of TodoItem objects upon success.
    • guard let url = URL(string: urlString) else { ... }: Safely attempts to create a URL object from our string. If it fails (e.g., malformed URL), it throws our custom invalidURL error.
    • let (data, response) = try await URLSession.shared.data(from: url): This is the magic of async/await!
      • URLSession.shared: Uses the default shared URL session for simple requests.
      • .data(from: url): This is an async throws method that performs the network request.
      • try await: We await its completion and try it because it can throw errors.
      • data contains the raw binary data from the server, and response contains metadata about the response (like status codes).
    • guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { ... }: Checks if the server responded with a successful HTTP status code (200 OK). If not, we throw a more specific error.
    • let decoder = JSONDecoder(): Creates an instance of JSONDecoder, which knows how to convert JSON into Swift types.
    • return try decoder.decode([TodoItem].self, from: data): This is where Decodable shines! We tell the decoder to try and convert the data into an array of TodoItem objects. If the JSON structure doesn’t match our TodoItem struct, a DecodingError will be thrown.
    • catch blocks: We specifically catch URLError (from URLSession), DecodingError (from JSONDecoder), and a general Error to wrap them in our NetworkError enum, providing consistent error reporting.

Step 4: Create a ViewModel to Manage State

Now, let’s create a ViewModel that orchestrates fetching data and updating the UI state. This class will be an ObservableObject so our SwiftUI views can react to its changes.

  1. Create a new Swift file named TodoListViewModel.swift.

  2. Add the following code:

    import Foundation
    import Combine // Needed for @Published
    
    // 1. Define the possible states of our data fetching
    enum LoadingState {
        case idle // No operation in progress
        case loading // Data is being fetched
        case loaded // Data has been successfully fetched
        case failed(Error) // An error occurred
    }
    
    // 2. Our ViewModel class, conforming to ObservableObject
    class TodoListViewModel: ObservableObject {
        // 3. @Published properties automatically notify SwiftUI when they change
        @Published var todos: [TodoItem] = []
        @Published var loadingState: LoadingState = .idle
    
        private let todoFetcher = TodoFetcher() // 4. Instance of our data fetcher
    
        // 5. Function to fetch data, called from our SwiftUI View
        func fetchTodos() async {
            // Ensure we don't fetch if already loading or loaded
            guard case .idle = loadingState else { return }
    
            loadingState = .loading // Set state to loading
            do {
                // Await the result from our fetcher
                let fetchedTodos = try await todoFetcher.fetchTodos()
                // Update properties on the main actor to ensure UI updates happen correctly
                // In modern Swift (Swift 6), this might be automatically inferred or require @MainActor
                await MainActor.run {
                    self.todos = fetchedTodos
                    self.loadingState = .loaded
                }
            } catch {
                // If an error occurs, update the loadingState to failed
                await MainActor.run {
                    self.loadingState = .failed(error)
                    print("Error fetching todos: \(error.localizedDescription)")
                }
            }
        }
    }
    

    Explanation:

    • import Combine: Required for @Published property wrapper.
    • enum LoadingState: A custom enum to clearly represent the different states of our data fetching process. This makes our UI logic much cleaner.
    • class TodoListViewModel: ObservableObject:
      • ObservableObject: This protocol enables SwiftUI views to subscribe to changes in this class’s @Published properties.
    • @Published var todos: [TodoItem] = []: An array to hold our fetched TodoItems. When this array changes, any SwiftUI view observing it will re-render.
    • @Published var loadingState: LoadingState = .idle: Tracks the current state of our data fetching.
    • private let todoFetcher = TodoFetcher(): An instance of our TodoFetcher to perform the actual network calls.
    • func fetchTodos() async: This async function is called from our SwiftUI view.
      • guard case .idle = loadingState else { return }: Prevents multiple simultaneous fetch requests if the app is already busy or data is already there. For a refresh mechanism, you might allow loaded to transition back to loading.
      • loadingState = .loading: Sets the state, which will update the UI to show a loading indicator.
      • let fetchedTodos = try await todoFetcher.fetchTodos(): Calls our TodoFetcher’s async throws method and awaits its result.
      • await MainActor.run { ... }: It’s crucial that any updates to UI-related @Published properties happen on the MainActor (main thread). While Swift 6’s strict concurrency checks often infer this, explicitly wrapping UI updates in MainActor.run is a robust best practice to prevent potential threading issues, especially for properties that directly drive UI.
      • catch error: If todoFetcher.fetchTodos() throws an error, we catch it and update loadingState to .failed, passing the error along.

Step 5: Design the SwiftUI View (ContentView)

Finally, let’s put it all together in our ContentView to display the data and handle different loading states.

  1. Open ContentView.swift.

  2. Replace its contents with the following:

    import SwiftUI
    
    struct ContentView: View {
        // 1. Create an instance of our ViewModel using @StateObject
        // @StateObject ensures the ViewModel persists across view updates
        @StateObject private var viewModel = TodoListViewModel()
    
        var body: some View {
            NavigationView { // 2. Provides navigation bar and title
                Group { // 3. Use Group to conditionally show different views
                    switch viewModel.loadingState {
                    case .idle:
                        // 4. Initial state: Prompt to load or automatically load
                        Color.clear // Invisible view
                            .onAppear {
                                // Trigger fetch when view appears (only once if idle)
                                Task {
                                    await viewModel.fetchTodos()
                                }
                            }
                    case .loading:
                        // 5. Show a loading indicator
                        ProgressView("Loading Todos...")
                    case .loaded:
                        // 6. Display the list of todos
                        List(viewModel.todos) { todo in
                            HStack {
                                Text(todo.title)
                                Spacer()
                                Image(systemName: todo.completed ? "checkmark.circle.fill" : "circle")
                                    .foregroundColor(todo.completed ? .green : .red)
                            }
                        }
                    case .failed(let error):
                        // 7. Display an error message with a retry button
                        VStack {
                            Image(systemName: "exclamationmark.triangle.fill")
                                .font(.largeTitle)
                                .foregroundColor(.red)
                            Text("Failed to load todos.")
                                .font(.headline)
                                .padding(.bottom, 5)
                            Text(error.localizedDescription) // Show localized error
                                .font(.subheadline)
                                .multilineTextAlignment(.center)
                                .padding(.horizontal)
                                .padding(.bottom)
    
                            Button("Retry") {
                                // Reset state to idle before retrying
                                viewModel.loadingState = .idle
                                Task {
                                    await viewModel.fetchTodos()
                                }
                            }
                            .buttonStyle(.borderedProminent)
                        }
                    }
                }
                .navigationTitle("My Todo List") // 8. Set navigation bar title
            }
        }
    }
    
    // MARK: - Previews
    struct ContentView_Previews: PreviewProvider {
        static var previews: some View {
            ContentView()
        }
    }
    

    Explanation:

    • @StateObject private var viewModel = TodoListViewModel(): This property wrapper is crucial for managing the lifecycle of our TodoListViewModel. It ensures that a single instance of TodoListViewModel is created and retained for the lifetime of ContentView, and that ContentView automatically re-renders whenever viewModel’s @Published properties change.
    • NavigationView: Provides a navigation bar at the top, allowing us to set a title.
    • Group: A container view that allows us to conditionally display different views based on the loadingState.
    • switch viewModel.loadingState: We use a switch statement to render different UI elements based on the current loadingState.
      • .idle: When the view first appears, onAppear is triggered. Inside onAppear, we launch a Task to call viewModel.fetchTodos(). Task is how you start an asynchronous operation from synchronous contexts (like view lifecycle methods).
      • .loading: Displays a ProgressView (a spinning indicator) with a message.
      • .loaded: Iterates over viewModel.todos using List.
        • List(viewModel.todos) { todo in ... }: Because TodoItem conforms to Identifiable, List can directly take the array. For each todo item, it creates an HStack.
        • HStack: Arranges Text and Image horizontally.
        • Image(systemName: ...): Uses Apple’s SF Symbols for visual cues (checkmark or circle) based on the completed status.
      • .failed(let error): Displays an error icon, a message, the localizedDescription of the error, and a “Retry” button. Tapping “Retry” resets the state to .idle and then again launches a Task to fetch data.
    • .navigationTitle("My Todo List"): Sets the title in the navigation bar.

Step 6: Run Your App!

  1. Select a simulator (e.g., “iPhone 15 Pro”) from the scheme dropdown next to the “Run” button.
  2. Click the “Run” button (or Cmd + R).

You should see your app launch, display “Loading Todos…”, and then populate with a list of todo items from the API! Try turning off your Wi-Fi to observe the error state.

Mini-Challenge: Enhance the Todo Item Display

You’ve built a functional app! Now, let’s make it a bit more engaging.

Challenge: Modify the HStack inside ContentView’s List to:

  1. Display the userId of each todo item next to its title.
  2. Change the text color of the title to gray if the todo.completed status is true.

Hint:

  • You can use Text("User: \(todo.userId)") to display the user ID.
  • The .foregroundColor() view modifier can be applied conditionally.

What to observe/learn:

  • How easily you can integrate additional data into your SwiftUI views.
  • The power of conditional view modifiers for dynamic UI.

Common Pitfalls & Troubleshooting

  1. Network Not Reachable / Simulator Issues:

    • Symptom: Your app shows the error message “Network request failed” or “The Internet connection appears to be offline.”
    • Troubleshooting:
      • Ensure your computer has an active internet connection.
      • Check if the simulator itself has network access. Sometimes restarting the simulator or Xcode can resolve transient network issues.
      • Verify the API URL (urlString) is correct and accessible in a web browser.
      • Remember that iOS simulators might sometimes have cached network states; a full reboot of the simulator (Hardware > Restart) can help.
  2. Decoding Errors (DecodingError):

    • Symptom: Your app shows an error like “Failed to decode data: The data couldn’t be read because it is missing.” or “The data couldn’t be read because it isn’t in the correct format.”
    • Troubleshooting:
      • Check TodoItem properties: Double-check that the property names (userId, id, title, completed) in your TodoItem struct exactly match the keys in the JSON response from the API (case-sensitive!).
      • Check TodoItem types: Ensure the data types (e.g., Int, String, Bool) of your TodoItem properties match the types returned in the JSON. For example, if the API sends 1 as a string "1", your Swift type should be String, not Int.
      • Inspect JSON structure: Use a browser or a tool like Postman to fetch the API response and carefully examine its structure. Sometimes, an API might return a single object instead of an array, or the keys might be nested differently.
      • Print raw data: Temporarily add print(String(data: data, encoding: .utf8) ?? "Could not convert data to string") before decoder.decode in TodoFetcher to see the raw JSON being received. This helps verify what the API is actually sending.
  3. UI Not Updating (@StateObject / MainActor):

    • Symptom: Your data fetches successfully (you can see print statements in the console), but the UI doesn’t change from the loading state or doesn’t display the data.
    • Troubleshooting:
      • @StateObject: Ensure your TodoListViewModel is instantiated with @StateObject in your ContentView. If you used @ObservedObject or just a regular var, SwiftUI might not correctly observe changes or might recreate the ViewModel unexpectedly.
      • @Published: Verify that todos and loadingState properties in TodoListViewModel are marked with @Published. Without it, SwiftUI won’t be notified of changes.
      • MainActor.run: Confirm that any updates to @Published properties (like self.todos = fetchedTodos and self.loadingState = .loaded) are performed on the MainActor using await MainActor.run { ... }. While Swift 6’s strict concurrency aims to make this less error-prone, explicit dispatch is always safe.

Summary

Congratulations! You’ve successfully built your first data-driven iOS application using modern Swift and SwiftUI. This chapter covered essential concepts and practices:

  • Project Setup: Initiating an iOS project in Xcode.
  • Data Modeling: Defining Decodable and Identifiable Swift structs to represent API data.
  • Asynchronous Networking: Leveraging URLSession with async/await for efficient and readable network requests.
  • Structured Error Handling: Implementing a custom NetworkError enum and using do-catch blocks for robust error management.
  • State Management: Utilizing ObservableObject and @Published properties within a ViewModel to manage the app’s loading, loaded, and error states.
  • Dynamic UI with SwiftUI: Employing List, ForEach, NavigationView, and conditional views (Group with switch) to display data and react to different application states.
  • Lifecycle Management: Using @StateObject to ensure ViewModel persistence and onAppear with Task to initiate data fetching.

This mini-project is a foundational step. You’ve now experienced the full loop of fetching external data, modeling it in Swift, and displaying it in a user interface. This knowledge will be invaluable as you move on to building more complex, interactive, and production-ready iOS applications.

References


This page is AI-assisted and reviewed. It references official documentation and recognized resources where relevant.