Image

In modern iOS development with Swift, concurrency is everywhere. Whether you’re fetching data, uploading files, or processing images, Task has become the de facto way to perform asynchronous work.

But there’s a pattern we often see in codebases: async work is treated as something separate from the app’s state.

var isLoading = false
private var task: Task<Response, Never>?

These two variables try to describe a single concept — “work is in progress” — but each has a different concern. isLoading is for the UI. task is for cancellation. You now have two sources of truth for one fact, and the cost of inconsistency is high.

In this post, we’ll explore the idea that side effects are state, and how managing them explicitly with an EffectsState structure improves clarity, consistency, and control.


Why Treat Tasks as State?

  • ✅ To show accurate UI feedback
  • ✅ To query if an effect is running, in order to …
  • ✅ … cancel them later
  • ✅ … avoid duplicate requests

Introducing EffectsState

Instead of tracking both flags and Task references, we propose having a single source of truth: a shared structure that manages running tasks. For the sake of simplicity, the following example only allows 1 running task/effect per EffectId, AND our first example is not thread-safe, wait for the following examples.

final class EffectsState<EffectID: Hashable> {
    private var tasks: [EffectID: Task<Void, Error>] = [:]

    deinit {
        cancelAll()
    }

    func isRunning(_ id: EffectID) -> Bool {
        tasks[id] != nil
    }

    func start(
        _ id: EffectID,
        operation: @escaping @Sendable () async throws -> Void
    ) {
        self.tasks[id] = Task { [weak self] in
            try await operation()
            self?.tasks.removeValue(forKey: id)
        }
    }

    func cancel(_ id: EffectID) {
        tasks[id]?.cancel()
        tasks.removeValue(forKey: id)
    }

    func cancelAll() {
        tasks.values.forEach { $0.cancel() }
        tasks.removeAll()
    }
}

Usage Example

Let’s say we’re viewing a user profile and want to fetch profile data without duplication:

enum EffectID {
    case loadProfile
}

class MyModel: ObservableObject {
    let effects = EffectsState<EffectID>()
    let api: API = API()
    @State var profile: Profile = Profile(userName: "")
    
    func loadUserProfile() throws {

        // Do not execute simultaneous equal effects,
        //  we may either effects.cancel(.loadProfile)
        guard !effects.isRunning(.loadProfile) else {
            return
        }
        
        effects.start(.loadProfile) { [api] in
            let profile = try await api.fetchProfile()
            await MainActor.run {
                self.profile = profile
            }
        }
    }
}

This:

  • Prevents double calls (isRunning)
  • Manages task lifecycle
  • Cleans up automatically when the task finishes
  • Cancels automatically on deinit

Building the UI

UI feedback can be calculated from the app’s state. Remember SwiftUI’s dogma “A view is just the rendering of your state — nothing more, nothing less.”

extension MyModel {
    struct Shows {
        let userName: String
        let isLoading: Bool
    }
    var shows: Shows {
        Shows(
            userName: profile.userName,
            isLoading: effects.isRunning(.loadProfile)
        )
    }
}

And further code, following the patterns proposer earlier in the blog:

struct MyView: View {
    var shows: EffectsAreState.MyModel.Shows
    var body: some View {
        Text(shows.userName)
        if shows.isLoading {
            ProgressView()
        }
    }
}
struct MyViewModel: View {
    @ObservedObject var model: MyModel = MyModel()
    var body: some View {
        MyView(shows: model.shows)
    }
}

Canceling Specific Effects

Suppose the user taps “Cancel” while a background operation is running:

effects.cancel(.loadProfile)

Or if we want to cancel everything (e.g. when a screen disappears):

effects.cancelAll()

Keeping Thread Safety in Mind

Tasks might start from:

  • The main actor (UI-triggered actions)
  • Background threads (system events, observers, etc.)

So, we need a more sophisticated EffectsState, thread safe, ready for corner cases, solid and fully tested.

We consider this a key point that should exist on every application, as part of the main architecture/framework. At some point taks management may be necessary, and using ad-hoc solutions on every part of the code may result in duplicated and no solid task management.

Please see a first step (still not complete but showing the therad-safety point) below:

  • Use a DispatchQueue with .barrier for write safety
  • Use .sync for reads (safe from any thread)
  • Avoid direct mutation of shared dictionaries on other threads
final class EffectsState<EffectID: Hashable> {
    private let lock = DispatchQueue(label: "EffectsState.lock", attributes: .concurrent)
    private var tasks: [EffectID: Task<Void, Error>] = [:]

    deinit {
        cancelAll()
    }

    func isRunning(_ id: EffectID) -> Bool {
        lock.sync {
            tasks[id] != nil
        }
    }

    func start(
        _ id: EffectID,
        operation: @escaping @Sendable () async throws -> Void
    ) {
        let task = Task { try await operation() }

        lock.async(flags: .barrier) {
            self.tasks[id] = task
        }

        Task { [weak self] in
            _ = try await task.value
            self?.remove(id)
        }
    }

    func cancel(_ id: EffectID) {
        lock.sync {
            tasks[id]?.cancel()
        }
        remove(id)
    }

    func cancelAll() {
        lock.sync {
            tasks.values.forEach { $0.cancel() }
        }
        lock.async(flags: .barrier) {
            self.tasks.removeAll()
        }
    }

    private func remove(_ id: EffectID) {
        lock.async(flags: .barrier) {
            self.tasks.removeValue(forKey: id)
        }
    }
}

EffectsState in Statoscope

We’ve build Statoscopeas a basic library for mobile development, so of course Statoscope includes its own EffectsState. However it’s evolved to a much more complicates example, covering

  • Effects are not immediately running, instead an EffectsRunner orchestrates them
  • Effects are either anonymous closures or types inheriting from a protocol Effect. Only concrete types can be queried or cancelled by type (instead of id). Anonymous have been created for simplicity.
  • Effect types replaces EffectIds, so multiple effects with the same type can be executed if needed.
  • Thread safety is built on top of modern Swift Actors

You can see the EffectsState interface here


Conclusion

If your async work matters, treat it like state. By managing side effects through a centralized EffectsState structure, you gain:

  • A single, consistent place to track ongoing work
  • Built-in cancellation
  • Easier testability and UI feedback
  • Cleaner resource management

This doesn’t eliminate complexity — it acknowledges it and gives it a name.

Let your app declare what’s happening, not just what it’s displaying.