Task Cancellation in Infinite Lists
Goal
By the end of this tutorial, you will build a scrolling SwiftUI list where each row loads its own details and cancels work cleanly when the row scrolls away.
State it explicitly:
“Show a feed where rows can load details on demand without keeping useless tasks alive.”
Requirements
- Basic SwiftUI knowledge
- Xcode 15+
- iOS 17+
The use case
Infinite lists often load lightweight preview data first, then fetch richer information when a row becomes visible. If the user scrolls quickly, some rows disappear before the fetch finishes. That is normal. What is not normal is letting those off-screen rows keep doing work that no longer matters.
Step 1 - The simplest working version
Start with a list that loads details when each row appears.
import SwiftUI
struct FeedItem: Identifiable {
let id: UUID
let title: String
}
struct FeedScreen: View {
let items: [FeedItem]
var body: some View {
List(items) { item in
FeedRow(item: item)
}
}
}
struct FeedRow: View {
let item: FeedItem
@State private var detail: String = "Loading..."
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
.task {
detail = await loadDetail()
}
}
}
This works, but it has a problem: the task is not tied to the row’s identity. If the row gets reused or the view changes, the work may not behave the way you expect.
Quick verification: Run the list and confirm each row can show its own detail text.
Step 2 - Make it reusable
Now tie the async work to the specific item.
struct FeedRow: View {
let item: FeedItem
@State private var detail: String = "Loading..."
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
.task(id: item.id) {
detail = await loadDetail()
}
}
}
What changed: The task now restarts when the row’s identity changes. That keeps the async work attached to the correct row instead of a stale view instance.
The row is still reusable because it only needs an item and some local display state.
Quick verification: Swap the list data or trigger a refresh and confirm each row still shows the right detail.
Step 3 - SwiftUI-specific refinement
Add cancellation awareness so rows stop doing work when they are no longer visible.
struct FeedRow: View {
let item: FeedItem
@State private var detail: String = "Loading..."
var body: some View {
VStack(alignment: .leading, spacing: 4) {
Text(item.title)
.font(.headline)
Text(detail)
.font(.caption)
.foregroundStyle(.secondary)
}
.task(id: item.id) {
do {
try Task.checkCancellation()
detail = try await loadDetail()
} catch {
detail = "Unavailable"
}
}
}
}
You can also make the loading function clearly asynchronous and cancel-friendly:
func loadDetail() async throws -> String {
try await Task.sleep(for: .seconds(1))
return "Loaded detail"
}
The important part is not the fake network delay. It is the fact that the task is tied to row identity and cancellation can stop useless work.
Quick verification: Scroll quickly so several rows disappear before loading completes, then confirm the app does not keep updating rows that are no longer visible.
Result
You now have a list that is:
- responsive while scrolling
- safe for row-level async loading
- aligned with SwiftUI identity and cancellation behavior
- easier to reason about under fast user interaction
That matters because infinite lists are one of the places where async work and UI reuse collide the most.
The same pattern also scales to richer row content. If a row needs an avatar, a subtitle, or a short remote summary, the task can still stay local as long as the identity is stable. That keeps the list responsive without moving all loading logic into one giant screen model.
Common SwiftUI pitfalls
-
Mistake: Starting the fetch in
onAppearand assuming it runs once
Why it happens:onAppearfeels lifecycle-like
How to fix it: tie the work totask(id:)and make the identity explicit -
Mistake: Letting every row keep a task alive after it scrolls away
Why it happens: the async work is not cancellation-aware
How to fix it: usetask, check cancellation, and keep the row state local -
Mistake: Storing loaded detail in a shared object for all rows
Why it happens: centralizing state looks simpler at first
How to fix it: keep row-local state local unless multiple screens need it -
Mistake: Forgetting that cancellation is part of the design Why it happens: the task works in the happy path, so cancellation gets ignored How to fix it: test fast scrolling and refreshes, then confirm old work stops instead of updating stale rows
When NOT to use this
If the data should be loaded once for the whole screen, do not start one task per row. Load it at the screen boundary and pass the result down.
Takeaway
If you remember one thing: Task cancellation works best when the task belongs to the identity that needs the data.
Next steps
If you want to keep async work and list identity aligned, these are the best follow-ups: