All skills

Generates pagination infrastructure with offset or cursor-based patterns, infinite scroll, and search support. Use when user wants to add paginated lists, infinite scrolling, or load-more functionality.

Use this Skill: https://skilld.dev/gh/rshankras/claude-code-apple-skills/pagination

This session only. Nothing lands on disk.

pagination-patterns.md

≈2.3k tokens on demand. Your agent reads this file only when SKILL.md points to it.

Pagination Patterns and Best Practices

Offset vs Cursor Pagination

Offset-Based

GET /users?page=2&size=20

Response:

{
  "items": [...],
  "totalItems": 150,
  "totalPages": 8,
  "currentPage": 2,
  "pageSize": 20
}
Pros Cons
Simple to implement Inconsistent on inserts/deletes
Jump to any page Slow on large datasets (SQL OFFSET)
Know total count Duplicate/missing items when data changes
Easy "Page X of Y" UI

Best for: Admin dashboards, search results, stable datasets.

Cursor-Based

GET /users?cursor=eyJpZCI6MTAwfQ&limit=20

Response:

{
  "items": [...],
  "nextCursor": "eyJpZCI6MTIwfQ",
  "hasMore": true
}
Pros Cons
Consistent with real-time data Can't jump to arbitrary page
Performant at any depth No total count (usually)
No duplicates/gaps Opaque cursor (can't inspect)
Works with infinite scroll

Best for: Social feeds, chat, real-time data, large datasets.

Decision Guide

Is data frequently inserted/deleted?
├── Yes → Cursor-based
└── No
    ├── Need "jump to page N"? → Offset-based
    └── Infinite scroll only? → Either works, cursor preferred

State Machine

Pagination has well-defined states. Model them explicitly:

                    ┌─────────┐
                    │  idle    │  (initial state)
                    └────┬────┘
                         │ loadFirstPage()
                    ┌────▼────┐
              ┌─────│ loading  │─────┐
              │     └─────────┘     │
          success              failure
              │                     │
        ┌─────▼─────┐      ┌───────▼──────┐
        │  loaded    │      │    error      │
        │ (has more) │      │ (retryable)   │
        └─────┬─────┘      └───────┬───────┘
              │ loadNextPage()     │ retry()
        ┌─────▼─────┐             │
        │  loading   │◄────────────┘
        │  (next)    │
        └─────┬─────┘
              │
        ┌─────▼──────┐
        │  exhausted  │  (no more pages)
        └────────────┘

State Enum

enum PaginationState: Equatable {
    case idle
    case loading
    case loadingMore      // Loading subsequent pages (items already visible)
    case loaded
    case error(String)
    case exhausted        // All pages loaded, no more data
}

Key State Rules

  1. idle → loading: Only on first load
  2. loaded → loadingMore: On subsequent pages (keeps existing items visible)
  3. error → loading/loadingMore: On retry
  4. loaded → exhausted: When response has fewer items than page size or hasMore == false
  5. Any → idle: On refresh/reset

Why "loadingMore" is Separate from "loading"

  • loading: Show full-screen loading spinner, no items visible
  • loadingMore: Show items + small bottom spinner, user can still scroll

Threshold Prefetching

The Problem

If you wait until the user scrolls to the very bottom to load the next page, they see a loading spinner every time. Bad UX.

The Solution: Prefetch Threshold

Load the next page when the user is N items from the bottom:

func onItemAppear(_ item: Item) {
    let threshold = 5  // Load when 5 items from bottom
    guard let index = items.firstIndex(where: { $0.id == item.id }),
          index >= items.count - threshold else {
        return
    }
    Task { await loadNextPage() }
}

Choosing a Threshold

Page Size Threshold Why
10 3 Small pages load fast
20 5 Standard
50 10 Large pages need more lead time

Rule of thumb: threshold = pageSize / 4, minimum 3.

Pull-to-Refresh

Reset Everything on Refresh

func refresh() async {
    items.removeAll()
    currentPage = 0
    cursor = nil
    state = .idle
    await loadFirstPage()
}

SwiftUI Integration

List {
    // ... items
}
.refreshable {
    await manager.refresh()
}

Search + Pagination

The Challenge

Search queries change pagination context:

  • New query → reset to page 1
  • Same query, scroll → load next page
  • Empty query → show all (or clear)

Debouncing

Don't fire API request on every keystroke:

@Observable
final class SearchablePaginationManager<Source: PaginatedDataSource> {
    var query: String = "" {
        didSet {
            debounceTask?.cancel()
            debounceTask = Task {
                try await Task.sleep(for: .milliseconds(300))
                await resetAndSearch()
            }
        }
    }

    private var debounceTask: Task<Void, Error>?

    private func resetAndSearch() async {
        pagination.reset()
        await pagination.loadFirstPage()
    }
}

Cancel Previous Requests

When query changes, cancel in-flight requests:

func loadFirstPage() async {
    currentTask?.cancel()
    currentTask = Task {
        try Task.checkCancellation()
        let response = try await dataSource.fetch(query: query, page: firstPage)
        try Task.checkCancellation()  // Check again before updating UI
        self.items = response.items
    }
    try? await currentTask?.value
}

Empty States

What to Show

State What to Show
idle Nothing (or skeleton)
loading (first page) Full-screen spinner or skeleton
loadingMore Items + bottom spinner
loaded (0 items) Empty state illustration
error (first page) Full-screen error with retry
error (next page) Items + error banner + retry
exhausted Items + "No more results" footer

ContentUnavailableView (iOS 17+)

if manager.items.isEmpty && manager.state == .loaded {
    ContentUnavailableView(
        "No Results",
        systemImage: "magnifyingglass",
        description: Text("Try a different search term")
    )
}

Error Handling

Retryable vs Non-Retryable

enum PaginationError: Error {
    case networkError(Error)    // Retryable
    case decodingError(Error)   // Usually not retryable
    case cancelled              // Not an error, ignore

    var isRetryable: Bool {
        switch self {
        case .networkError: return true
        case .decodingError: return false
        case .cancelled: return false
        }
    }
}

Don't Lose Items on Error

// ❌ Wrong: clear items on next-page error
func loadNextPage() async {
    do {
        let response = try await fetch()
        items = response.items  // Replaces existing!
    } catch {
        items = []  // User loses everything
    }
}

// ✅ Right: keep existing items, show error for retry
func loadNextPage() async {
    do {
        let response = try await fetch()
        items.append(contentsOf: response.items)  // Append
    } catch {
        state = .error(error.localizedDescription)  // Items preserved
    }
}

Performance Considerations

Diffable Data

Use Identifiable items for efficient SwiftUI diffing:

protocol PaginatedDataSource {
    associatedtype Item: Identifiable & Sendable
    // ...
}

Avoid Duplicate Requests

Guard against concurrent page loads:

func loadNextPage() async {
    guard state == .loaded else { return }  // Not idle, loading, or exhausted
    state = .loadingMore
    // ... fetch
}

Memory Management for Large Lists

For lists with thousands of items, consider:

  • LazyVStack (not List) for better memory behavior
  • Limit in-memory items and reload from cache
  • Use .onDisappear to release image data

Anti-Patterns to Avoid

Don't Use Array Index as Page Number

// ❌ Fragile — breaks if items are filtered or reordered
let page = items.count / pageSize

// ✅ Track page number explicitly
var currentPage = 0

Don't Ignore Task Cancellation

// ❌ Stale response overwrites newer data
let response = try await fetch(page: 2)
self.items = response.items  // Page 3 response may have already arrived

// ✅ Check cancellation
let response = try await fetch(page: 2)
try Task.checkCancellation()
self.items.append(contentsOf: response.items)

Don't Paginate on the Main Actor

// ❌ Blocks UI during fetch
@MainActor func loadNextPage() async {
    let data = try await URLSession.shared.data(from: url)  // Blocks UI
}

// ✅ Fetch off main, update on main
func loadNextPage() async {
    let response = try await dataSource.fetch(page: nextPage)
    await MainActor.run { items.append(contentsOf: response.items) }
}

Source: SKILL.md on GitHub

No alerts2mo3 checks · Risk SAFE
  • Gen Agent Trust Hub2mo

    The skill is a code generation utility designed to help developers implement pagination in Swift and SwiftUI applications. It provides robust, best-practice templates for offset and cursor-based pagination, including state management and UI components. No security risks, malicious patterns, or unauthorized data access were identified.

  • Socket2mo

    No alerts

  • Snyk2mo

    Risk: LOW · No issues

Signed by skilld at 32566aa. This ties the file your Agent reads to that commit on GitHub. It does not review the instructions.

Last checked against GitHub 2 months ago.

Steadyupdated 3 months ago
What it can do
Reads files Edits files Runs commands
last_verified
2026-07-16
review_by
2027-06-22
os_version
iOS 27 / macOS 27
All 7 allowed tools
ReadWriteEditGlobGrepBashAskUserQuestion

README badge

README badge for rshankras/claude-code-apple-skills/pagination