If you are an iOS developer moving your codebase to modern Swift (or you just turned on Strict Concurrency Checking), you have probably experienced the same thing.

Some time ago, I was working on a SwiftUI project and noticed my build log full of yellow warnings. The most common one was this:

Property isolated to global actor ‘MainActor’ cannot be referenced from a non-isolated context

At first, I just wanted the warnings to disappear.

I added Task { @MainActor in ... } everywhere just to silence the noise.

But then I realized something: I didn’t actually understand why Swift was complaining.

So I decided to look deeper into the idea of isolation. What I learned completely changed how I think about my code.

Here is what I learned, so you don’t have to struggle with it alone.

The Mental Shift: From Threads to Islands

In the old days (GCD and DispatchQueue), we mostly thought about threads.

We worried about two threads touching the same variable at the same time and crashing the app.

Swift Concurrency asks us to stop thinking about threads and start thinking about islands instead.

🌊 The Sea (Non-Isolated)

This is the default state.

Normal classes, structs, and functions live in the “sea” of concurrency. Anyone can swim up and access them at any time. This can be dangerous when the data can change.

🏝️ The Islands (Actors)

An actor (including @MainActor) is a protected island. It has walls around it.

  • Only one thing can run on the island at a time

  • If you are in the sea and want to read or change data on the island, you must wait at the dock using await

The Most Common Trap: MainActor

In SwiftUI, your Views and usually your ViewModels live on the MainActor island. This is the most important island, because the UI lives there.

Most warnings appear when you are floating in the sea (for example, in a background task, delegate method, or completion handler) and you try to reach the island to change a property without waiting properly.

Below are the three situations I ran into most often, and how to fix them the right way.

1. The Background Update

The Scenario
You fetch data in a background Task and try to update a @Published property on your ViewModel.

The Warning

Property ‘state’ isolated to global actor ‘MainActor’ cannot be mutated from a non-isolated context

The Fix
You must jump back onto the island.

func loadData() {
    Task {
        // We are in the "Sea" (background)
        let data = await api.fetch()

        // ❌ You can't touch the UI from the Sea
        // self.state = .loaded(data)

        // ✅ Hop onto the MainActor island
        await MainActor.run {
            self.state = .loaded(data)
        }
    }
}

2. The Protocol Conformance

The Scenario
Your @MainActor ViewModel conforms to a standard protocol (for example, a delegate).

The Warning

MainActor-isolated instance method cannot be used to satisfy a non-isolated protocol requirement

The Why
The protocol definition lives in the sea. It promises that its methods can be called immediately from anywhere.

But your ViewModel lives on the island and says, “You can only call me from the main thread”. These two promises don’t match.

The Fix
If the function does not touch the UI, mark it as nonisolated. This tells Swift: “I promise this function does not need the island’s protection.”

@MainActor
class ViewModel: MyDelegate {

    // ✅ Allow the Sea to call this directly
    nonisolated func didFinishWork() {
        print("Work finished!")
    }
}

3. The “Legacy API” Confusion

The Scenario
This one confused me the most. I was using NotificationCenter with queue: .main.

NotificationCenter.default.addObserver(
    forName: .updated,
    object: nil,
    queue: .main
) { _ in
    self.updateUI()
}

I was sure this code runs on the main thread. So why doesn’t the compiler trust me?

The Lesson
Swift Concurrency checks code at compile time. queue: .main is a runtime guarantee.

The compiler only sees a normal closure, so it treats it as non-isolated, even if we know it will run on the main thread.

Fix 1: The Performance-Friendly Way

If you are absolutely sure you are already on the main thread, you can use assumeIsolated.

NotificationCenter.default.addObserver(
    forName: .updated,
    object: nil,
    queue: .main
) { _ in
    MainActor.assumeIsolated {
        self.updateUI()
    }
}

This avoids creating a new async task.

Fix 2: The Modern Swift Way

The better long-term solution is to avoid closure-based APIs and use Swift’s AsyncSequence instead.

Task {
    // This Task runs on the MainActor
    for await _ in NotificationCenter.default.notifications(named: .updated) {
        self.updateUI()
    }
}

This approach handles isolation and memory management for you, with no warnings and no tricks.

Conclusion

These warnings can be annoying, but they exist to protect us from race conditions that used to crash our apps in unpredictable ways.

Once I stopped fighting the compiler and started visualizing my code as islands, the solutions became much clearer.

If you are stuck with these warnings, ask yourself:

“Am I in the sea, or am I on an island?”

Happy coding 🚀