Guides Pratiques

Requêtes infinies

Implémenter la pagination avant et bidirectionnelle avec InfiniteQueryObserver : chargement au scroll, pull-to-refresh, pagination fenêtrée et APIs basées sur curseur.

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

posts_screen.dart
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 :

posts_screen.dart
// 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

OptionTypeRequisDescription
initialPageParamTPageParamOuiParamètre pour la toute première page. Typiquement 1, 0, ou un curseur vide.
getNextPageParamTPageParam? Function(TData lastPage, List<TData> allPages)OuiRetourner le paramètre de la page suivante, ou null quand il n'y a plus de pages.
getPreviousPageParamTPageParam? Function(TData firstPage, List<TData> allPages)?:Retourner le paramètre de la page précédente. Nécessaire uniquement pour le défilement bidirectionnel.
maxPagesint?:Pages maximum en mémoire (pagination fenêtrée). Quand dépassé, la page la plus ancienne est supprimée.
baseOptionsQoraOptions:Options standard de retry, stale time et network mode appliquées à chaque fetch de page.

Stratégies de pagination

Numéros de page entiers

posts_screen.dart
InfiniteQueryOptions<List<Post>, int>(
  initialPageParam: 1,
  getNextPageParam: (lastPage, allPages) =>
      lastPage.hasMore ? allPages.length + 1 : null,
)

Basé sur curseur

feed_screen.dart
InfiniteQueryOptions<FeedPage, String?>(
  initialPageParam: null,   // null = "démarrer depuis le début"
  getNextPageParam: (lastPage, allPages) => lastPage.nextCursor,
)

Offset/limite

search_screen.dart
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 :

chat_screen.dart
InfiniteQueryOptions<List<Message>, String?>(
  initialPageParam: null,   // Démarrer depuis le dernier message
  getNextPageParam: (lastPage, allPages) => lastPage.olderCursor,
  getPreviousPageParam: (firstPage, allPages) => firstPage.newerCursor,
)
chat_screen.dart
// 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 :

feed_screen.dart
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).

Pour la plupart des applications, 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() :

posts_screen.dart
// 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 :

anywhere.dart
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 :

posts_screen.dart
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 :

posts_screen.dart
// 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

Copyright © 2026