Requêtes infinies
Une requête infinie récupère des données par pages et accumule les résultats entre les requêtes. Les pages sont ajoutées en fin ou en début au lieu de remplacer les données existantes.
Qora implémente ce pattern avec InfiniteQueryObserver, InfiniteData et InfiniteQueryState.
La machine d'état
InfiniteInitial
│
│ observer.fetch()
▼
InfiniteLoading ← chargement de la page initiale uniquement
│
├── success ──▶ InfiniteSuccess
│ │
│ ├── fetchNextPage() ──▶ InfiniteSuccess (isFetchingNextPage: true)
│ ├── fetchPreviousPage() ──▶ InfiniteSuccess (isFetchingPreviousPage: true)
│ └── fetch failure ──▶ InfiniteFailure (avec previousData)
│
└── failure ──▶ InfiniteFailure
Conception clé : après le chargement de la première page, l'état ne retourne jamais à InfiniteLoading. Les fetchs de pages suivantes signalent leur progression via isFetchingNextPage / isFetchingPreviousPage sur InfiniteSuccess, de sorte que les pages existantes restent visibles pendant le chargement de plus de données.
Configuration de base
final observer = InfiniteQueryObserver<List<Post>, int>(
client: client,
key: ['posts'],
fetcher: (page) => api.getPosts(page: page),
options: InfiniteQueryOptions(
initialPageParam: 1,
// Retourner null quand il n'y a plus de pages.
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : null,
),
);
// Récupérer la première page
await observer.fetch();
// S'abonner aux changements d'état
observer.stream.listen((state) {
switch (state) {
case InfiniteInitial() || InfiniteLoading():
showSpinner();
case InfiniteSuccess(:final data, :final hasNextPage):
final posts = data.flatten((page) => page);
renderList(posts, hasNextPage: hasNextPage);
case InfiniteFailure(:final error):
showError(error);
}
});
// Plus tard : déclencher la page suivante (ex. depuis un listener de scroll)
await observer.fetchNextPage();
// Toujours disposer quand terminé
observer.dispose();
InfiniteData<TData, TPageParam>
InfiniteData est un conteneur immuable qui maintient toutes les pages chargées et les paramètres utilisés pour les récupérer :
state.data.pages // List<TData> : toutes les pages dans l'ordre
state.data.pageParams // List<TPageParam> : params utilisés pour chaque page
state.data.pageCount // int
state.data.isEmpty // bool
flatten : des pages aux éléments
flatten convertit une liste de pages en liste plate d'éléments pour ListView.builder :
// TData == List<Post>
final posts = data.flatten((page) => page);
// TData est un objet de réponse avec une liste imbriquée
final posts = data.flatten((page) => page.items);
InfiniteQueryOptions
| Option | Type | Requis | Description |
|---|---|---|---|
initialPageParam | TPageParam | Oui | Paramètre pour la toute première page. Typiquement 1, 0, ou un curseur vide. |
getNextPageParam | TPageParam? Function(TData lastPage, List<TData> allPages) | Oui | Retourner le paramètre de la page suivante, ou null quand il n'y a plus de pages. |
getPreviousPageParam | TPageParam? Function(TData firstPage, List<TData> allPages)? | : | Retourner le paramètre de la page précédente. Nécessaire uniquement pour le défilement bidirectionnel. |
maxPages | int? | : | Pages maximum en mémoire (pagination fenêtrée). Quand dépassé, la page la plus ancienne est supprimée. |
baseOptions | QoraOptions | : | Options standard de retry, stale time et network mode appliquées à chaque fetch de page. |
Stratégies de pagination
Numéros de page entiers
InfiniteQueryOptions<List<Post>, int>(
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : null,
)
Basé sur curseur
InfiniteQueryOptions<FeedPage, String?>(
initialPageParam: null, // null = "démarrer depuis le début"
getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
)
Offset/limite
const pageSize = 20;
InfiniteQueryOptions<List<Result>, int>(
initialPageParam: 0,
getNextPageParam: (lastPage, allPages) {
final nextOffset = allPages.length * pageSize;
return lastPage.length == pageSize ? nextOffset : null;
},
)
Défilement bidirectionnel
Les fils de chat, timelines et fils de commentaires doivent souvent charger dans les deux directions. Fournir getPreviousPageParam pour activer la pagination en arrière :
InfiniteQueryOptions<List<Message>, String?>(
initialPageParam: null, // Démarrer depuis le dernier message
getNextPageParam: (lastPage, allPages) => lastPage.olderCursor,
getPreviousPageParam: (firstPage, allPages) => firstPage.newerCursor,
)
// Défilement vers le haut → charger les messages plus anciens
await observer.fetchPreviousPage();
// Défilement vers le bas → charger les messages plus récents
await observer.fetchNextPage();
Pagination fenêtrée (maxPages)
Pour les sessions de défilement longues (fils de type Twitter/X), une liste de pages non bornée cause une utilisation mémoire croissante et des saccades UI. maxPages limite la fenêtre en mémoire :
InfiniteQueryOptions<List<Post>, int>(
initialPageParam: 1,
getNextPageParam: (lastPage, allPages) =>
lastPage.hasMore ? allPages.length + 1 : null,
maxPages: 10, // Garder au maximum 10 pages en mémoire
)
Quand la fenêtre glisse :
fetchNextPage: supprime la page la plus ancienne en tête.fetchPreviousPage: supprime la page la plus récente en queue.
Le bord supprimé redevient navigable : quand l'utilisateur l'atteint, hasPreviousPage (ou hasNextPage) devient true et Qora re-récupère la page supprimée en utilisant getPreviousPageParam (ou getNextPageParam). Cela requiert que l'API supporte des paramètres en accès aléatoire (numéros de page stables ou curseurs).
maxPages: 5–10 fournit un bon équilibre. Chaque page contient 20–50 éléments, donc 10 pages ≈ 200–500 éléments en mémoire : bien dans les capacités de Flutter.Refetch après invalidation
Quand les données deviennent périmées (ex. une mutation change l'état serveur), appeler refetch() :
// Après création d'un nouveau post
await api.createPost(title);
await observer.refetch(); // Refetch toutes les pages chargées en séquence
refetch() re-récupère chaque page dans la fenêtre courante en utilisant ses pageParams originaux, en préservant le nombre de pages. Il signale sa progression via isFetchingNextPage: true sur l'état InfiniteSuccess existant : les données existantes restent visibles.
L'invalidation peut également se faire depuis n'importe où dans l'application :
client.invalidateInfiniteQuery(['posts']);
// L'observer détecte InfiniteInitial et refetch automatiquement
Gestion des erreurs
Les erreurs lors de fetchNextPage ou fetchPreviousPage transitionnent vers InfiniteFailure tout en préservant previousData :
case InfiniteFailure(:final error, :final previousData):
if (previousData != null) {
// Afficher le contenu existant + une bannière d'erreur
return Column(children: [
PostList(items: previousData.flatten((p) => p)),
ErrorBanner(message: 'Échec du chargement : $error'),
]);
}
// Pas encore de données chargées : afficher l'écran d'erreur complet
return ErrorScreen(error);
Le retry est automatique pour les fetchs de pages (contrôlé par baseOptions.retryCount, défaut 3). Pour le binding Flutter, voir InfiniteQueryBuilder.
Mises à jour optimistes
Écriture directe dans le cache infini pour un retour UI immédiat :
// Snapshot avant la mutation
final snapshot = client.getInfiniteQueryData<List<Post>, int>(['posts']);
// Prépendre optimistement le nouveau post à la première page
if (snapshot != null) {
final optimisticData = InfiniteData<List<Post>, int>(
pages: [
[Post.optimistic(title), ...snapshot.pages.first],
...snapshot.pages.skip(1),
],
pageParams: snapshot.pageParams,
);
client.setInfiniteQueryData(['posts'], optimisticData);
}
try {
await api.createPost(title);
client.invalidateInfiniteQuery(['posts']);
} catch (_) {
// Rollback
if (snapshot != null) client.setInfiniteQueryData(['posts'], snapshot);
}
Étapes suivantes
- InfiniteQueryBuilder : Lier les requêtes infinies à l'UI Flutter en un seul widget
- Référence API Requêtes Infinies : Documentation API complète
- Guide des Mutations : Combiner les mutations avec l'invalidation du cache
Réseau adaptatif
Gérer le mode hors-ligne, les requêtes en pause, la file de mutations hors-ligne et la relecture à la reconnexion avec la couche réseau de Qora.
Requêtes dépendantes
Utiliser QoraOptions.dependsOn pour chaîner les requêtes de sorte qu'une ne se déclenche qu'après la résolution d'une autre.