Fiche 07.09 — Réseau de production : pagination (offset/limit et cursor)
Objectif
Charger une liste longue par morceaux au lieu de tout télécharger d'un coup. Tu vas voir les deux stratégies (offset/limit vs cursor), un ViewModel @Observable propre avec les bons états, le déclenchement du « load more », la fusion + déduplication des pages, et le pull-to-refresh.
1. Pourquoi paginer
Une liste de 10 000 lignes, tu ne la télécharges jamais d'un bloc : payload énorme, latence, mémoire, et l'utilisateur ne voit que les 10 premières. Tu charges donc une page à la fois et tu en demandes une nouvelle quand l'utilisateur approche du bas.
En UIKit, tu faisais ça à la main dans tableView(_:willDisplay:forRowAt:) (ou scrollViewDidScroll) en comparant indexPath.row au seuil. En SwiftUI, le déclencheur, c'est .onAppear sur le dernier item (ou .task). Le mécanisme de fond reste identique.
2. Offset/limit vs cursor
Deux façons de dire au serveur « donne-moi la suite ».
Offset / limit — GET /items?offset=40&limit=20. Tu demandes « 20 items à partir du 40e ».
- Avantage : trivial à implémenter, on peut sauter directement à une page N.
- Inconvénient : incohérent si la liste bouge. Si un item est inséré pendant que tu scrolles, tout décale → doublons ou items sautés. Lourd côté DB sur les gros offsets (
OFFSET 100000scanne 100000 lignes).
Cursor (curseur) — GET /items?cursor=eyJpZCI6NDB9&limit=20. Le serveur te renvoie un jeton opaque qui pointe sur « après le dernier item que tu as vu ».
- Avantage : stable même si la liste change (le curseur encode une position, pas un index). Performant côté DB (
WHERE id > ?). C'est le standard des grosses API (Stripe, GitHub, Slack). - Inconvénient : pas d'accès direct à une page arbitraire, seulement « suivant ». Le client ne connaît pas le total.
Réflexe : cursor par défaut en production, offset/limit seulement si l'API impose ce format ou pour un dataset petit et stable.
3. L'APIClient
On part d'un client async/await qui renvoie une page typée cursor.
4. Le ViewModel @Observable
Les états distincts sont la clé : premier chargement et chargement de page suivante ne donnent pas la même UI.
Points importants ici :
guard currentItem.id == items.last?.id: on ne déclenche le load more que sur le vrai dernier item, pas à chaque cellule visible.guard hasMore, !isLoadingMore: la garde anti-double-chargement. Sans elle, un scroll rapide lance 3 requêtes pour la même page.mergedéduplique : indispensable, car offset comme cursor peuvent renvoyer un item déjà présent (insertion concurrente, chevauchement de page).refresh()remetnextCursorethasMoreà zéro avant de recharger : sinon tu repars du mauvais curseur.
5. La vue : états distincts + déclenchement
.task sur la ligne est préférable à .onAppear : il est async (pas besoin de wrapper dans Task { }) et il s'annule automatiquement si la cellule disparaît avant la fin. La garde currentItem.id == items.last?.id dans le ViewModel fait le filtrage, donc poser le .task sur chaque ligne ne coûte rien.
6. Variante offset/limit
Si l'API ne propose pas de cursor, tu remplaces juste l'état de pagination. Le service expose alors une méthode offset/limit (func fetchPage(offset: Int, limit: Int) async throws -> Page<Article>) au lieu de fetchArticles(cursor:limit:). Le reste du ViewModel est identique.
Note le risque : si un item est inséré entre deux requêtes, offset décale et tu peux avoir un doublon (rattrapé par merge) ou un item sauté (non rattrapable). C'est exactement la fragilité d'offset/limit.
Points à connaître
- Pas de garde anti-double-chargement = bug n°1. Un scroll rapide ou un re-render lance plusieurs fois la même page. Toujours
guard !isLoadingMore. - Toujours dédupliquer par
id. Ne fais jamais confiance au serveur pour ne pas renvoyer de chevauchement, surtout avec offset/limit. - Ne confonds pas les deux loadings.
isLoadingFirstPage→ skeleton/plein écran ;isLoadingMore→ spinner en bas. Réutiliser le même flag affiche un spinner plein écran à chaque page, l'app paraît cassée. - Reset complet au refresh. Remets
nextCursor/loadedCountethasMoreà zéro, sinon le pull-to-refresh repart de la mauvaise position.
Exercice
Pars d'une List simple branchée sur un service paginé (cursor) :
- Ajoute
loadMoreIfNeeded(currentItem:)au ViewModel avec garde anti-double-chargement ethasMore. - Branche le déclencheur via
.tasksur la ligne et affiche unProgressViewen bas pendantisLoadingMore. - Ajoute
.refreshable { await viewModel.refresh() }qui reset le curseur et recharge la première page.
Faisable en 15-20 min. Vérifie qu'un scroll rapide ne déclenche qu'une requête par page.
Question d'entretien
« Tu as une liste de 10 000 items à afficher. Offset ou cursor ? »
Cursor en production. Offset/limit est simple mais incohérent dès que la liste change (doublons/items sautés sur décalage) et coûteux côté DB sur les gros offsets (OFFSET 9000 scanne 9000 lignes). Le cursor encode une position opaque, reste stable sous insertions concurrentes et se traduit par un WHERE id > ? indexé. Côté UI, je charge par pages de ~20 avec un déclencheur .task sur le dernier item, une garde anti-double-chargement, et LazyVStack/List pour que SwiftUI ne monte que les cellules visibles.
« Comment évites-tu de lancer plusieurs fois la requête de page suivante ? »
Un flag isLoadingMore posé avant l'appel et remis à false dans un defer, plus une garde guard !isLoadingMore, hasMore. Et je ne déclenche que sur le vrai dernier item (currentItem.id == items.last?.id), pas sur chaque cellule visible.
« Comment gères-tu un item qui apparaît en double entre deux pages ? »
Déduplication par id à la fusion : je construis un Set des ids existants et je n'ajoute que les nouveaux. Indispensable car le serveur peut renvoyer un chevauchement, surtout en offset/limit sous écriture concurrente.
Résumé
- Paginer = charger par pages, demander la suite quand on approche du bas.
- Cursor : stable, performant, standard prod. Offset/limit : simple mais fragile si la liste bouge.
- ViewModel
@Observable:items,nextCursor/loadedCount,isLoadingFirstPage,isLoadingMore,hasMore,errorMessage. - Déclencheur :
.tasksur le dernier item + garde anti-double-chargement. - Toujours fusionner avec déduplication par
id. - Deux loadings distincts : skeleton plein écran (1re page) vs spinner en bas (page suivante).
- Pull-to-refresh (
.refreshable) = reset complet du curseur puis rechargement.