swiftui · essay · performance · architecture

Side Effects in SwiftUI: task vs onAppear vs onChange

Published August 10, 2026 · 4 min read · intermediate

The confusion

SwiftUI makes side effects feel deceptively simple. You see onAppear, task, and onChange, and they all look like reasonable places to “do something when the screen updates.”

That impression causes a lot of bugs. A fetch runs twice. An analytics event fires on every redraw. A refresh fires when a view reappears, but the team expected it to happen only once.

The confusion comes from treating view updates like lifecycle events. SwiftUI does not work that way.

What SwiftUI is actually doing

SwiftUI recomputes view descriptions often. That means a view can appear, disappear, and be reconfigured more than once over the lifetime of a screen.

The three APIs are different tools:

  • task is for asynchronous work tied to the view’s lifetime
  • onAppear is for visibility-related work
  • onChange is for responding to a specific state transition

They are not interchangeable. If you use them as if they were, the framework will eventually expose the mismatch.

The mental model

Side effects should follow the boundary that owns the trigger.

If the trigger is “this value changed,” use onChange. If the trigger is “this view became visible,” use onAppear or task. If the trigger is “start async work while this view exists,” use task.

This mental model keeps you from hiding business logic in a place that only happens to work in previews or the first run.

A small proof

struct FeedView: View {
    @State private var query = ""

    var body: some View {
        List {
            TextField("Search", text: $query)
        }
        .task(id: query) {
            await loadResults(for: query)
        }
        .onChange(of: query) { _, newValue in
            logSearchTerm(newValue)
        }
    }
}

This snippet shows the difference clearly. task(id:) is good for async loading that should cancel and restart when query changes. onChange is better for lightweight reactions like logging or validation. Neither is a substitute for proper ownership of the search state itself.

Another example is a detail screen that needs to fetch data once when it becomes visible. If you use onAppear without a guard, it may fetch again when navigation or identity changes cause the view to reappear. If you use task with an id, the behavior is more explicit and easier to reason about.

Why this matters in real apps

The practical impact is reliability. When side effects are tied to the right boundary, you avoid duplicate network calls, accidental double analytics, and stale loading state. That matters in forms, lists, dashboards, and anything where SwiftUI may refresh the view more often than a UIKit developer expects.

It also improves code reviews. Once the team agrees that task means async work, onChange means state reaction, and onAppear means visibility, the intent becomes obvious in a few lines.

Two common real-world patterns benefit from this clarity:

  1. A list screen that loads content when the query changes should use task(id:), not repeated onAppear work.
  2. A settings screen that validates input as the user types should use onChange, not an async task that wakes up for every character.

The better the boundary, the fewer surprises later.

Where this model breaks down

Not every effect fits cleanly into one bucket. Some work needs debouncing, cancellation, or coordination with a model layer. In those cases, the view should still stay small and delegate the harder behavior to a service or view model.

The model also breaks down if you put the effect in the wrong place and hope the framework will make it safe. task does not make expensive work cheap. onChange does not make side effects idempotent. And onAppear does not guarantee one-time execution.

So the rule is not “pick your favorite hook.” The rule is “attach the effect to the exact state or visibility boundary that causes it.”

One sentence to remember

In SwiftUI, side effects belong to the boundary that triggers them, not to the nearest convenient hook.

Next steps

If you want to keep those boundaries clear, these are the best follow-ups: