Swift Actors Demo from Network to UI

Swift Actor Demo From Network to UI

This section guides you through building a small iOS app that demonstrates modern Swift concurrency using actors. We’ll tackle common problems like data races and redundant network requests, building a safe and efficient image loading system.

The “Why”: Solving Data Races with Actors

Before we code, let’s understand the problem. In concurrent programming, a data race occurs when multiple threads try to access and change the same piece of data at the same time, leading to unpredictable and incorrect results. Imagine two cashiers trying to update the same inventory count on paper simultaneously—the final number would be chaos!

Actors are Swift’s solution. Think of an actor as a “gatekeeper” for its data. It’s a special kind of Swift type that protects its own state by ensuring only one task can modify it at a time. All outside requests are lined up and handled one by one, preventing data races by design.

Key Terms You’ll See:

  1. actor: A type that protects its internal state from concurrent access.
  2. await: The keyword you use to call an actor’s methods from the outside. It tells your code to pause if necessary until the actor is free to respond.
  3. @MainActor: A special global actor that ensures code runs on the main UI thread, which is required for all UI updates.
  4. @globalActor: A pattern for creating your own app-wide, shared actor to protect a resource like the file system.

Project Overview

You’ll create:

  • ImageCache — An actor that deduplicates downloads and stores image data in memory.
  • DiskActor and ImageDisk — A @globalActor to safely serialize file I/O.
  • ArticleImageVM — A @MainActor view model.
  • ContentView — A SwiftUI view to display the image and interact with the model.

File: ImageCache.swift What this file does: This file defines ImageCache, an actor designed to safely download and cache image data in memory. It masterfully avoids two common networking problems: data races and redundant downloads.

  1. Actor-Based State Protection: By being an actor, all access to its internal dictionaries (inMemory and inFlight) is automatically protected. We don’t need to manually use locks or queues; the Swift compiler handles it for us.

  2. Deduplicating Downloads: If multiple parts of your app request the same image at once, you don’t want to start a dozen identical downloads. The inFlight dictionary is the key. It stores the network Task itself. If a new request comes in for a URL that’s already being downloaded, it doesn’t start a new task. Instead, it just awaits the result of the existing, in-flight task. This is a very efficient pattern.

  3. Cleanup with defer: The defer { inFlight[url] = nil } block is crucial. It guarantees that the task is removed from the inFlight dictionary when the function exits, regardless of whether the download succeeded or failed. This ensures the cache is always in a clean state.

import Foundation

actor ImageCache {
  private var inMemory: [URL: Data] = [:]
  private var inFlight: [URL: Task<Data, Error>] = [:]

  func data(for url: URL) async throws -> Data {
    if let d = inMemory[url] { return d }
    if let t = inFlight[url] { return try await t.value }

    let t = Task { [url] () async throws -> Data in
      let (data, resp) = try await URLSession.shared.data(from: url)
      guard (resp as? HTTPURLResponse)?.statusCode == 200 else {
        throw URLError(.badServerResponse)
      }
      return data
    }
    inFlight[url] = t
    defer { inFlight[url] = nil }

    let data = try await t.value
    inMemory[url] = data
    return data
  }
}

File: DiskActor.swift What this file does: Declares a custom global actor, DiskActor, to serialize all file system access.

Why a @globalActor? The file system is a shared resource. Writing to the same file from multiple places at once can lead to data corruption. A global actor provides a single, app-wide “gatekeeper” to ensure that all disk reads and writes are performed serially, not concurrently. Any function or type marked with @DiskActor will have its work automatically dispatched to this actor.

Why an enum for ImageDisk? We use an enum here as a namespace. Since we only need a place to group related static helper functions (url, read, write) and don’t need to create instances of ImageDisk, an enum is a lightweight and conventional choice in Swift. By marking the entire enum with @DiskActor, all its static methods are automatically isolated to that actor.

import Foundation

@globalActor
actor DiskActor: GlobalActor {
  static let shared = DiskActor()
}

@DiskActor
enum ImageDisk {
  static func url(for key: String) -> URL {
    FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
      .appendingPathComponent("\(key).img")
  }
  static func read(for key: String) -> Data? {
    try? Data(contentsOf: url(for: key))
  }
  static func write(_ data: Data, for key: String) {
    try? data.write(to: url(for: key), options: .atomic)
  }
}

File: ImageCache+Disk.swift What this file does: Extends ImageCache with a new function to add a disk persistence layer, demonstrating cross-actor communication.

The dataWithDisk function is running on an ImageCache actor instance. Notice the await ImageDisk.read(for: key). We must use await because we are calling a function that runs on a different actor (DiskActor). Our ImageCache actor has to “hop” over to the DiskActor to read the file, pausing its own work until the result is returned.

Once the data is back, the line inMemory[url] = disk is perfectly safe without an await. Why? Because we are back inside the ImageCache actor’s own context, where we have direct, synchronous access to its state.

import Foundation

extension ImageCache {
  func dataWithDisk(for url: URL) async throws -> Data {
    let key = (url.absoluteString.addingPercentEncoding(withAllowedCharacters: .alphanumerics) ?? "img")
    if let disk = await ImageDisk.read(for: key) {
      inMemory[url] = disk
      return disk
    }
    let data = try await data(for: url)
    await ImageDisk.write(data, for: key)
    return data
  }
}

File: ArticleImageVM.swift What this file does: A view model that orchestrates the image loading and exposes the final Image to the SwiftUI view. It uses modern Swift features for safe and efficient UI updates.

  • @MainActor: All UI updates must happen on the main thread. Marking the entire class with @MainActor guarantees that its properties (like image) are only ever modified on the main thread, preventing UI-related crashes.

  • @Observable: This macro from the new Observation framework (iOS 17+) automatically makes SwiftUI views react to changes in the view model’s properties. When the image property is set, any view using it will automatically reload.

  • Task { ... }: The load function itself isn’t async. We launch an unstructured Task to perform the asynchronous work of fetching data from the cache. Because the view model is on the @MainActor, any code inside this Task after an await will resume on the main thread, making the final assignment to self.image safe.


File: ContentView.swift What this file does: Reads the view model from the environment, shows a progress indicator while loading, and allows the user to toggle the disk cache behavior. It uses .task to trigger the initial load when the view appears.

import SwiftUI
import Observation

struct ContentView: View {
  @Environment(ArticleImageVM.self) private var vm
  private let url = URL(string: "https://httpbin.org/image/png")!

  var body: some View {
    VStack(spacing: 16) {
      if let img = vm.image {
        img.resizable().scaledToFit().frame(maxHeight: 240)
      } else {
        ProgressView("Loading…")
      }

      Toggle("Use Disk Cache", isOn: Binding(
        get: { vm.useDisk },
        set: { vm.useDisk = $0 }
      ))
      .padding(.horizontal)

      HStack {
        Button("Reload") { vm.load(from: url) }
        Button("Clear Image") { vm.image = nil }
      }
    }
    .padding()
    .task { vm.load(from: url) }
  }
}

File: ActorsDemoApp.swift What this file does: The app’s entry point. It creates a single instance of our ArticleImageVM and injects it into the SwiftUI environment.

The .environment(vm) modifier is the modern way to pass an @Observable object down the view hierarchy. Any child view (like ContentView) can then access this shared instance using the @Environment property wrapper.

import SwiftUI
import Observation

@main
struct ActorsDemoApp: App {
  @State private var vm = ArticleImageVM()

  var body: some Scene {
    WindowGroup {
      ContentView()
        .environment(vm) // inject Observation model into environment
    }
  }
}

Playground Smoke Test (Optional)

What this code does: A minimal, console-based demo to prove the core concept of actor-based safety. The SafeTicketOffice simulates selling a limited number of tickets (just one). We then try to buy two tickets at the same time using async let.

  • With an actor (Correct): The first call to buy() enters the actor and pauses on Task.sleep. The second call lines up and waits; it cannot start until the first one is completely finished. By then, the available count is 0, so the second call correctly fails. The final remaining() count is 0.

  • If this were a class (Incorrect): A standard class offers no such protection. The second call would barge in while the first was paused. Both calls would see available as 1, and you’d end up “selling” two tickets when you only had one. The actor prevents this race condition entirely.

import Foundation
import PlaygroundSupport
PlaygroundPage.current.needsIndefiniteExecution = true

actor Counter {
  private var value = 0
  func increment() { value += 1 }
  func get() -> Int { value }
}

actor SafeTicketOffice {
  private var available = 1
  func buy() async throws {
    guard available > 0 else { throw NSError(domain: "soldout", code: 0) }
    available -= 1
    do {
      try await Task.sleep(nanoseconds: 100_000_000) // simulate work
    } catch {
      available += 1 // Important: restore state if task is cancelled
      throw error
    }
  }
  func remaining() -> Int { return available }
}

let counter = Counter()
let office = SafeTicketOffice()

Task {
  await counter.increment()
  print("Counter =", await counter.get())

  async let first: Void = { try? await office.buy() }()
  async let second: Void = { try? await office.buy() }()
  _ = await (first, second)

  print("Tickets left =", await office.remaining()) // Should print 0
  PlaygroundPage.current.needsIndefiniteExecution = false
}
See forum comments
Download course materials from Github
Previous: A Guide to Swift Actors Next: Conclusion