Sitemap
Константин Клинов

Константин Клинов

Mastering Elegant Pagination in SwiftUI Using Modern Swift Concurrency

2 min readJun 8, 2026

--

Infinite scrolling is a staple of modern mobile applications, but implementing it correctly has historically been a headache. With the enforcement of Swift 6 Strict Concurrency Checking, the old workarounds utilizing @Published wrappers and loosely managed onAppear closures are no longer acceptable.

Get Константин Клинов’s stories in your inbox

Join Medium for free to get updates from this writer.

Today, we will walk through a robust, thread-safe, and elegant pagination implementation utilizing @Observable, @MainActor, and structured tasks.

The Modern Concurrency Paradigm

In the current ecosystem, avoiding data races is a compiler requirement, not just a suggestion. To achieve smooth pagination, we must ensure two things:

  1. Background Execution: The actual data fetching must not block the UI.
  2. Main Thread Mutations: Updating the list array must happen strictly on the main thread.

Step 1: The @Observable View Model

By marking our class with @Observable and @MainActor, we ensure our UI reacts instantly to changes and all properties are isolated safely.

import SwiftUI

@MainActor
@Observable
final class PaginationViewModel {
private(set) var items: [String] = []
private(set) var isLoading = false
private var currentPage = 1
private(set) var canLoadMore = true

func loadNextPage() async {
// Prevent duplicate concurrent requests
guard !isLoading && canLoadMore else { return }

isLoading = true
// Ensure isLoading is reset regardless of success or failure
defer { isLoading = false }

do {
let newItems = try await fetchItems(page: currentPage)
if newItems.isEmpty {
canLoadMore = false
} else {
items.append(contentsOf: newItems)
currentPage += 1
}
} catch {
print("Pagination error: \(error)")
}
}

// Mock network call
private func fetchItems(page: Int) async throws -> [String] {
try await Task.sleep(for: .seconds(1))
return (1...20).map { "Item \($0 + (page - 1) * 20)" }
}
}

Step 2: The View Implementation

SwiftUI’s .task modifier is the hero here. By attaching it to a ProgressView at the bottom of our list, it triggers the fetch operation only when the user scrolls near the end. Furthermore, .task is bound to the view's lifecycle—if the user navigates away, the network request is automatically cancelled.

struct PaginatedListView: View {
@State private var viewModel = PaginationViewModel()

var body: some View {
NavigationStack {
List {
ForEach(viewModel.items, id: \.self) { item in
Text(item)
.padding(.vertical, 8)
}

// Pagination Trigger
if viewModel.canLoadMore {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
.task {
await viewModel.loadNextPage()
}
}
}
.navigationTitle("Modern Pagination")
// Initial Data Load
.task {
if viewModel.items.isEmpty {
await viewModel.loadNextPage()
}
}
}
}
}

Open to New Opportunities

I am an experienced Senior iOS Developer actively looking for a new role in a product team. I specialize in building highly scalable, beautifully animated applications utilizing modern Swift architectures.

--

--