SwiftUI & Interface Design

Chapter 10
Data Persistence, Networking, and Concurrency

Certificate in IOS Development
SwiftUI & Interface Design   •   Certificate

Data Persistence, Networking, and Concurrency

Introduction

A SwiftUI interface becomes useful when it represents data that survives app launches, arrives from a web service, and remains responsive while work occurs in the background. These requirements connect three areas of application development: persistence, networking, and concurrency.

Persistence stores information locally. Networking transfers information between an app and a remote service. Concurrency allows time-consuming work—such as reading a database or downloading JSON—to proceed without blocking the main interface. In a well-designed application, these concerns are coordinated through a predictable data flow:

  1. The view requests data from a model or service.
  2. An asynchronous task loads local or remote data.
  3. The result is decoded, validated, and published.
  4. SwiftUI redraws the interface according to the new state.
  5. Changes are persisted and, where appropriate, synchronized with a server.

This chapter develops these ideas using modern Swift features, including Codable, URLSession, structured concurrency, async/await, actors, error handling, caching, and secure data practices.

Learning Objectives

By the end of this chapter, you should be able to:

  • distinguish in-memory state from durable local persistence;
  • encode and decode Swift models using Codable;
  • select an appropriate local storage approach for a small SwiftUI application;
  • construct network requests with URLRequest and URLSession;
  • use async/await, tasks, and cancellation safely;
  • represent loading, success, empty, and failure states in SwiftUI;
  • implement basic caching and offline-friendly behavior;
  • apply secure practices to credentials, transport, personal data, and logs;
  • organize a small application using services and observable view models.

1. The Data Layer of a SwiftUI Application

SwiftUI views should describe what the user sees rather than contain networking, database, and authentication logic. A useful separation has three levels:

LayerMain responsibilityTypical examples
ViewPresents state and sends user intentionsList, ProgressView, buttons
View modelCoordinates state and operations@Observable model, loading state
Service/repositoryPerforms persistence and network operationsAPI client, file store

A model represents domain information, such as a task, article, or product. A service performs a technical operation. A repository offers a domain-oriented interface that may combine local storage, caching, and a remote API.

This separation improves testability. A view model can be tested with a fake repository rather than a live server. It also limits the impact of changing a storage technology or API format.

A common state model is:

enum LoadState<Value> {
    case idle
    case loading
    case loaded(Value)
    case failed(AppError)
}

An application may also represent an empty result explicitly. Empty data is not necessarily an error: a successful request that returns no tasks differs from a failed request caused by a network outage.

2. Local Persistence and Codable

2.1 Volatile and durable data

SwiftUI property wrappers such as @State and @StateObject manage in-memory values. These values normally disappear when the process terminates. Durable persistence stores data in a form that can be reconstructed later.

Common choices include:

  • UserDefaults: small preferences and simple settings; not a general database.
  • File storage: JSON or other files in the app’s sandbox; suitable for small collections and documents.
  • Keychain: secrets such as tokens and passwords.
  • SwiftData or Core Data: structured object persistence, relationships, querying, and larger datasets.
  • SQLite-based solutions: direct control over relational storage and queries.

The correct choice depends on data size, query complexity, relationships, sensitivity, and synchronization requirements.

2.2 Encoding models

Codable is a type alias combining Encodable and Decodable. It allows a type to convert to and from formats such as JSON, provided its stored properties are themselves codable.

struct TaskItem: Codable, Identifiable, Equatable {
    let id: UUID
    var title: String
    var isComplete: Bool
    var updatedAt: Date
}

A JSON encoder converts a Swift value into data; a decoder reconstructs a Swift value from data. Date and key strategies should be configured consistently:

let encoder: JSONEncoder = {
    let encoder = JSONEncoder()
    encoder.dateEncodingStrategy = .iso8601
    encoder.outputFormatting = [.prettyPrinted]
    return encoder
}()

let decoder: JSONDecoder = {
    let decoder = JSONDecoder()
    decoder.dateDecodingStrategy = .iso8601
    return decoder
}()

The formatting option is convenient for inspection but is not required in production. A server using snake-case keys can be supported with keyDecodingStrategy = .convertFromSnakeCase, although explicit CodingKeys are preferable when API names are irregular or when precise control is required.

struct UserProfile: Codable {
    let displayName: String
    let avatarURL: URL

    enum CodingKeys: String, CodingKey {
        case displayName = "display_name"
        case avatarURL = "avatar_url"
    }
}

2.3 File persistence

A small file store can save an array of codable values in the app’s documents or application-support directory. Application Support is generally appropriate for app-managed files that users do not edit directly.

struct JSONFileStore<Value: Codable> {
    let fileURL: URL
    let encoder: JSONEncoder
    let decoder: JSONDecoder

    func load() throws -> Value {
        let data = try Data(contentsOf: fileURL)
        return try decoder.decode(Value.self, from: data)
    }

    func save(_ value: Value) throws {
        let data = try encoder.encode(value)
        try FileManager.default.createDirectory(
            at: fileURL.deletingLastPathComponent(),
            withIntermediateDirectories: true
        )
        try data.write(to: fileURL, options: .atomic)
    }
}

Atomic writing reduces the risk that an interruption leaves a partially written file. Persistence can still fail because of insufficient space, permissions, malformed data, migration problems, or file protection rules. These failures should be handled rather than ignored.

2.4 UserDefaults and Keychain

UserDefaults is appropriate for values such as a selected appearance mode or whether onboarding has been completed. It is not suitable for authentication tokens or large arrays. Values in UserDefaults are not designed to provide secret storage.

The Keychain is designed for sensitive credentials. A production application should use a carefully designed Keychain wrapper, define an accessibility policy, and avoid printing secrets. A refresh token should never be placed in a URL, ordinary JSON preferences, analytics event, or debug log.

3. Networking with URLSession

3.1 HTTP concepts

A network request contains a method, URL, headers, and sometimes a body. Common methods include:

  • GET: retrieve a resource;
  • POST: submit or create a resource;
  • PUT or PATCH: update a resource;
  • DELETE: remove a resource.

The response includes a status code, headers, and body. A successful response is not defined merely by receiving bytes. For many APIs, status codes from 200 through 299 indicate success, while authentication errors, rate limits, client errors, and server errors require different handling.

3.2 A typed API client

The following client decodes a list of tasks and validates the HTTP response:

enum AppError: LocalizedError {
    case invalidResponse
    case httpStatus(Int)
    case decoding(Error)
    case transport(Error)
    case unauthorized
    case cancelled

    var errorDescription: String? {
        switch self {
        case .invalidResponse: return "The server response was invalid."
        case .httpStatus(let code): return "The server returned status code \(code)."
        case .decoding: return "The received data could not be read."
        case .transport: return "The network connection failed."
        case .unauthorized: return "Please sign in again."
        case .cancelled: return "The operation was cancelled."
        }
    }
}

struct APIClient {
    let session: URLSession
    let decoder: JSONDecoder
    let baseURL: URL

    func fetchTasks() async throws -> [TaskItem] {
        let url = baseURL.appending(path: "tasks")
        var request = URLRequest(url: url)
        request.httpMethod = "GET"
        request.setValue("application/json", forHTTPHeaderField: "Accept")

        do {
            let (data, response) = try await session.data(for: request)
            guard let httpResponse = response as? HTTPURLResponse else {
                throw AppError.invalidResponse
            }
            guard (200..<300).contains(httpResponse.statusCode) else {
                if httpResponse.statusCode == 401 {
                    throw AppError.unauthorized
                }
                throw AppError.httpStatus(httpResponse.statusCode)
            }
            do {
                return try decoder.decode([TaskItem].self, from: data)
            } catch {
                throw AppError.decoding(error)
            }
        } catch is CancellationError {
            throw AppError.cancelled
        } catch let error as AppError {
            throw error
        } catch {
            throw AppError.transport(error)
        }
    }
}

A real client may also need authentication headers, pagination, request identifiers, retry policy, and server-specific error decoding. URL construction should use URL and URLComponents rather than string concatenation, especially when query parameters contain spaces or reserved characters.

3.3 Transport security

Applications should use HTTPS. Apple platforms enforce App Transport Security unless exceptions are explicitly configured. Disabling those protections broadly is unsafe. If an exception is unavoidable, it should be narrowly scoped, documented, and reviewed.

Certificate pinning is a specialized control with operational costs: certificates change, and incorrect pinning can disable legitimate access. It should be adopted only when the threat model and maintenance process justify it.

4. Concurrency with async/await

4.1 Why concurrency matters

Network and file operations can take an unpredictable amount of time. Performing them synchronously on the main actor can make scrolling and interaction unresponsive. Swift concurrency expresses asynchronous operations in a sequential style while allowing the system to schedule work efficiently.

An async function may suspend at await. Suspension is not the same as blocking: the thread can perform other work while waiting. throws indicates that an operation may fail.

func refresh() async throws -> [TaskItem] {
    try await apiClient.fetchTasks()
}

4.2 Tasks and cancellation

A SwiftUI view can start work with .task:

struct TaskListView: View {
    @State private var viewModel = TaskListViewModel()

    var body: some View {
        content
            .task {
                await viewModel.loadIfNeeded()
            }
            .refreshable {
                await viewModel.refresh()
            }
    }

    @ViewBuilder
    private var content: some View {
        switch viewModel.state {
        case .idle, .loading:
            ProgressView()
        case .loaded(let tasks) where tasks.isEmpty:
            ContentUnavailableView("No Tasks", systemImage: "checklist")
        case .loaded(let tasks):
            List(tasks) { task in
                Text(task.title)
            }
        case .failed(let error):
            ContentUnavailableView(
                "Unable to Load",
                systemImage: "wifi.exclamationmark",
                description: Text(error.localizedDescription)
            )
        }
    }
}

SwiftUI cancels a task associated with a view when that view disappears or when the task’s identity changes. Code should cooperate with cancellation. Avoid catching CancellationError and then displaying it as an ordinary failure. Long operations may check cancellation explicitly:

try Task.checkCancellation()

For a button action, Task {} can launch asynchronous work, but the created task should be retained or designed so that cancellation is possible when appropriate.

4.3 Actors and the main actor

An actor protects mutable state from simultaneous access. This is important for an in-memory cache or token manager accessed by multiple tasks.

actor TaskCache {
    private var value: [TaskItem]?
    private var storedAt: Date?

    func read() -> [TaskItem]? {
        guard let value, let storedAt else { return nil }
        let age = Date().timeIntervalSince(storedAt)
        return age < 300 ? value : nil
    }

    func write(_ tasks: [TaskItem]) {
        value = tasks
        storedAt = Date()
    }
}

The cache’s five-minute lifetime is a policy choice, not a universal rule. It is measured in seconds because TimeInterval represents seconds. The cache should be accessed with await from outside the actor.

UI-observed models generally belong to the main actor. With the Observation framework, a view model may be declared as follows:

@MainActor
@Observable
final class TaskListViewModel {
    var state: LoadState<[TaskItem]> = .idle

    private let client: APIClient
    private let cache = TaskCache()

    init(client: APIClient) {
        self.client = client
    }

    func loadIfNeeded() async {
        guard case .idle = state else { return }
        await refresh()
    }

    func refresh() async {
        state = .loading
        do {
            if let cached = await cache.read() {
                state = .loaded(cached)
            }
            let tasks = try await client.fetchTasks()
            await cache.write(tasks)
            state = .loaded(tasks)
        } catch is CancellationError {
            return
        } catch let error as AppError {
            state = .failed(error)
        } catch {
            state = .failed(.transport(error))
        }
    }
}

The exact Observation imports and project deployment target depend on the SDK. The important design principle is that UI state is updated on the main actor, while services expose asynchronous operations.

5. Loading States, Errors, and User Experience

A robust interface distinguishes at least five situations:

SituationAppropriate presentation
Initial loadingProgress indicator or skeleton
Loaded dataContent
Successful empty resultEmpty-state explanation and action
Recoverable failureExplanation and retry action
Refreshing existing dataPreserve content while showing refresh feedback

Errors should be classified according to the action available to the user. A timeout may invite retrying. An authentication failure may require signing in. A decoding failure may indicate an incompatible server release and should be logged for diagnosis without exposing technical details to the user.

Do not display raw server messages blindly. They may contain internal information, inappropriate language, or sensitive data. Map technical errors to safe, localized messages while retaining diagnostic details in protected development or observability systems.

6. Caching and Offline-Friendly Design

Caching reduces latency, bandwidth, and server load. It can also improve usability when connectivity is poor. A simple stale-while-revalidate strategy is:

  1. show valid cached data immediately;
  2. request fresh data in the background;
  3. replace the cache when fresh data succeeds;
  4. retain cached data and show a non-blocking warning if refresh fails.

The URLSession HTTP cache can honor server cache headers. An application-level cache is useful when the app needs explicit freshness rules, persistence across launches, or domain-specific invalidation.

Caching requires an invalidation policy. Useful questions include:

  • How long may data remain stale?
  • Which user actions invalidate it?
  • Can two users share the same cached data?
  • Is the data sensitive and therefore unsuitable for unencrypted disk storage?
  • What happens after logout?

Never assume that cached data is authoritative. The server may reject an update because another device changed the record. Synchronization requires conflict handling, often based on server version numbers, timestamps, or explicit merge rules.

7. Secure Data Practices

Security is a lifecycle concern rather than a final feature. Important practices include:

  • use HTTPS and validate server responses;
  • store tokens in Keychain rather than UserDefaults;
  • request only necessary permissions and data;
  • avoid including personal information in logs, analytics, and crash reports;
  • remove cached private data during logout when appropriate;
  • validate decoded data before displaying or storing it;
  • use least-privilege server credentials and scoped tokens;
  • protect files with suitable platform data-protection options;
  • avoid hard-coding production secrets in source control;
  • treat client-side checks as supplementary because a malicious client can bypass them.

Codable validates structure, not business meaning. A decoded title may still be empty, an identifier may be invalid, or a date may be outside an acceptable range. Domain validation belongs after decoding and before the model is trusted.

Privacy regulations may impose requirements concerning purpose limitation, retention, access, deletion, and consent. The precise legal duties depend on jurisdiction and application context; developers should involve the responsible privacy and security specialists rather than treating a technical implementation as legal advice.

8. Applied Case: Field Notes Application

Background

Consider an illustrative application called Field Notes. Inspectors record site observations on an iPhone. They need to read previously downloaded notes, create new notes without connectivity, and upload them when a connection becomes available.

Problem

A first version stores notes only in @State. Notes disappear after termination. A later version performs a network request directly in the view body, causing duplicated requests and poor error handling. The application also stores an access token in UserDefaults.

Analysis

The requirements imply four separate responsibilities:

  1. a codable note model;
  2. durable local storage;
  3. an API client for synchronization;
  4. a view model that exposes loading and synchronization state.

The app must also distinguish a locally saved note from a server-confirmed note. A simple model might include a local identifier, server identifier, synchronization status, and modification date.

enum SyncStatus: String, Codable {
    case pendingUpload
    case synchronized
    case failed
}

struct FieldNote: Codable, Identifiable {
    let id: UUID
    var serverID: String?
    var text: String
    var createdAt: Date
    var syncStatus: SyncStatus
}

Approach and solution

The application stores notes in an application-support JSON file for the certificate-level prototype. A repository first reads the file so the interface can display existing notes quickly. It then asks the API client to upload pending notes and fetch server updates. The access token is obtained from a Keychain wrapper and inserted into an authorization header only for the request.

The view model is main-actor isolated. It publishes an array of notes, an activity state, and a user-facing error. Network work uses async/await; cancellation is respected when the user leaves the screen. A cache actor prevents simultaneous refreshes from corrupting in-memory cached data.

If upload fails because the device is offline, the note remains locally available with pendingUpload. If the server rejects the note because the token has expired, the app requests reauthentication. If the server reports a conflict, the app preserves the local note and asks the user to review the competing version rather than silently overwriting data.

Outcome

The interface remains responsive, notes survive relaunch, and the user receives meaningful feedback. The design also supports unit testing: a fake API client can simulate success, timeout, invalid JSON, unauthorized responses, and cancellation.

Learning points

The case demonstrates that persistence is not merely writing a file. A production data flow must define ownership, synchronization status, failure behavior, security boundaries, and user-visible state. Separating these concerns makes the application easier to extend from a prototype to a reliable product.

9. Testing and Maintainability

Test the data layer independently from SwiftUI. Useful tests include:

  • encoding and decoding representative models;
  • decoding missing, null, and unexpected fields;
  • handling non-2xx status codes;
  • handling malformed JSON;
  • verifying that cancellation is not presented as a network failure;
  • testing cache freshness and invalidation;
  • testing file-write failure and recovery;
  • confirming that logout removes or invalidates protected data.

Use URLProtocol or a mock transport to test networking without relying on a live server. Inject URLSession, clocks, file locations, and API clients rather than constructing all dependencies inside view models. A controllable clock makes cache-expiration tests deterministic.

API evolution should be planned. Additive server fields are often safe when decoders do not require them. Renamed fields, changed types, and removed fields require migration or compatibility logic. Local persisted data may outlive the version of the app that created it, so schema versioning and migration should be considered whenever the data becomes important.

10. Contemporary Directions and Limitations

Swift concurrency continues to influence Apple-platform architecture. Structured tasks, actors, task groups, and async sequences support clearer ownership of asynchronous work. SwiftData offers a modern persistence model for applications whose data requires relationships and queries, although a small JSON file may remain simpler for a small, self-contained dataset.

Modern APIs do not eliminate design decisions. async/await does not automatically make code thread-safe; actors do not resolve incorrect business rules; Codable does not guarantee valid domain data; and caching does not solve synchronization conflicts. Developers must still define lifecycle, ownership, cancellation, consistency, and security policies.

Best-Practice Checklist

Before releasing a data-driven SwiftUI feature, verify that:

  • models have clearly defined ownership and identifiers;
  • persistence is appropriate for data size and sensitivity;
  • JSON dates and keys use explicit, tested strategies;
  • HTTP status codes are checked;
  • loading, empty, success, failure, and cancellation are distinct;
  • UI state is isolated to the main actor;
  • shared mutable state is protected by an actor or another safe design;
  • retries are bounded and do not repeat non-idempotent operations carelessly;
  • cached data has an invalidation policy;
  • credentials are stored in Keychain;
  • logs and analytics exclude sensitive information;
  • services can be replaced by test doubles;
  • offline and conflict behavior is intentional.

Chapter Summary

Data-driven SwiftUI applications combine three related capabilities: durable local persistence, communication with remote services, and safe asynchronous execution. Codable provides a practical bridge between Swift models and JSON, while file storage, SwiftData, Core Data, UserDefaults, and Keychain serve different persistence needs. URLSession performs HTTP operations, but reliable networking also requires URL construction, status-code validation, decoding checks, and meaningful error classification.

Swift concurrency uses async/await to express asynchronous work without blocking the interface. SwiftUI tasks support lifecycle-aware loading and cancellation, while actors protect shared mutable state such as caches. A professional interface represents loading, loaded, empty, failed, and refreshing states explicitly. Caching can improve performance and offline usability but requires freshness, invalidation, privacy, and conflict policies. Secure development includes HTTPS, Keychain storage, careful logging, least privilege, validation, and appropriate data retention. Separating views, view models, repositories, and services makes these concerns testable and maintainable.

Key Terms

Persistence: Storing data so it remains available beyond the current process.

Volatile state: In-memory data that may disappear when an application terminates.

Codable: Swift’s combined Encodable and Decodable protocol capability.

Encoder: A component that converts a Swift value into serialized data.

Decoder: A component that reconstructs a Swift value from serialized data.

UserDefaults: A store for small, non-sensitive application preferences.

Keychain: A protected system facility for credentials and other secrets.

URLSession: Apple’s API for HTTP and URL-based network communication.

HTTP status code: A numeric response classification such as success, client error, or server error.

async/await: Swift syntax for expressing operations that may suspend while waiting.

Task: A unit of asynchronous work managed by Swift concurrency.

Cancellation: A cooperative request for asynchronous work to stop.

Actor: A concurrency-isolated reference type that protects mutable state.

Main actor: The actor associated with UI-related work.

Cache: A temporary or semi-durable copy used to reduce latency or repeated work.

Repository: An abstraction that provides domain data from one or more sources.

Serialization: Conversion between structured values and a transferable representation.

Transport security: Protection applied to data while it moves between systems.

Idempotent operation: An operation that can be repeated without changing the intended result beyond the first application.

Conflict: A condition in which local and remote versions of data cannot be combined automatically.

Review Questions

5 Short Answer Questions

  1. What is the difference between UserDefaults and Keychain?
  2. Why should an application check an HTTP response status code before decoding a response?
  3. What does await indicate in Swift concurrency?
  4. Why is an empty result different from a failed network request?
  5. Name two reasons an application might use a cache.

5 Long Answer Questions

  1. Explain how Codable supports local JSON persistence and identify two situations in which custom coding keys or strategies are useful.
  2. Describe the responsibilities of a SwiftUI view, view model, and service in a data-driven feature.
  3. Explain how cancellation works with a SwiftUI .task and why cancellation should not normally be displayed as an ordinary error.
  4. Compare file storage, SwiftData or Core Data, UserDefaults, and Keychain for different kinds of application data.
  5. Discuss the security risks of storing tokens, cached private data, and diagnostic information incorrectly.

3 Analytical / Application Questions

  1. Design a loading-state model for a screen that displays cached products while requesting fresh products. Explain how the interface should behave for cache hit, successful refresh, empty response, timeout, and cancellation.
  2. A notes application allows offline creation and later synchronization. Propose a model and synchronization workflow that handles pending uploads, expired credentials, and server conflicts.
  3. A developer places a network request directly in a SwiftUI view and stores its token in UserDefaults. Analyze the design problems and propose a testable, secure replacement architecture.

Multiple Choice Questions with Answers

Question 1

Which storage mechanism is most appropriate for an authentication refresh token?

A. UserDefaults

B. Keychain

C. A SwiftUI @State property

D. A cached image file

Correct Answer: B. Keychain

Question 2

What does JSONDecoder primarily do?

A. Encrypt network traffic

B. Create a URL request

C. Reconstruct a Swift value from serialized data

D. Display a loading indicator

Correct Answer: C. Reconstruct a Swift value from serialized data

Question 3

Why should URLSession code inspect the HTTP status code?

A. Receiving bytes always means the request succeeded

B. The status code indicates whether the server operation succeeded or failed

C. Status codes determine the device screen size

D. Status codes replace JSON decoding

Correct Answer: B. The status code indicates whether the server operation succeeded or failed

Question 4

What is the principal benefit of using async/await for a network request?

A. It guarantees that the server is available

B. It prevents all decoding errors

C. It allows asynchronous work to be expressed clearly without blocking the interface

D. It encrypts the response automatically

Correct Answer: C. It allows asynchronous work to be expressed clearly without blocking the interface

Question 5

Which type is designed to protect shared mutable state in Swift concurrency?

A. Actor

B. Tuple

C. Enumeration

D. Property wrapper only

Correct Answer: A. Actor

Question 6

Which state represents a successful request that returned no records?

A. Transport failure

B. Unauthorized

C. Successful empty result

D. Malformed response

Correct Answer: C. Successful empty result

Question 7

What is a suitable reason to use an application-level cache?

A. To eliminate the need for server validation

B. To show previously loaded data quickly while fresh data is requested

C. To store passwords in plain text

D. To bypass all HTTP status codes

Correct Answer: B. To show previously loaded data quickly while fresh data is requested

Question 8

What should normally happen when a SwiftUI task is cancelled because its view disappears?

A. Cancellation should be treated as a user-visible server failure

B. The operation should cooperate with cancellation and avoid presenting it as an ordinary error

C. The app should retry indefinitely

D. The app should save the token to UserDefaults

Correct Answer: B. The operation should cooperate with cancellation and avoid presenting it as an ordinary error

Question 9

Which practice best protects data in transit?

A. Using HTTP for all production requests

B. Putting tokens in URL query strings

C. Using HTTPS and narrowly reviewed transport exceptions

D. Printing request headers to logs

Correct Answer: C. Using HTTPS and narrowly reviewed transport exceptions

Question 10

What does Codable validate by itself?

A. The structural conversion between Swift values and serialized data

B. That every decoded title is meaningful to the business

C. That the server authenticated the user

D. That a cache is fresh

Correct Answer: A. The structural conversion between Swift values and serialized data

SwiftUI & Interface Design
Chapter 10 of 14