Exemples

Étude de cas : Feed à défilement infini

Construire un feed de type réseau social haute performance avec pagination par curseur, gestion mémoire fenêtrée et création de post optimiste instantanée.

Objectif

Contexte : Un feed à défilement infini semble simple : charger plus de données au fil du scroll. Les contraintes de production sont strictes : après 30 minutes de scroll, votre ListView contient 2 000 widgets, la mémoire explose et l'OS termine votre app. Par ailleurs, si vous utilisez des numéros de page entiers et qu'un post est inséré en cours de scroll, chaque page suivante est décalée d'un et les utilisateurs voient des doublons.

Cet exemple démontre deux contrôles fondamentaux. La pagination par curseur élimine la dérive des décalages. La pagination fenêtrée avec maxPages impose une empreinte mémoire bornée tout en permettant la récupération de pages sur demande.


Fonctionnalités démontrées

  • InfiniteQueryBuilder : accumule les pages et expose fetchNextPage / fetchPreviousPage via son controller
  • Pagination par curseur : résultats stables indépendamment des insertions côté serveur
  • Gestion mémoire fenêtrée avec maxPages : plafonne la fenêtre de pages en mémoire ; supprime les plus anciennes/récentes quand dépassé
  • isFetchingNextPage + isFetchingPreviousPage : pilote le loader de fin de scroll sans masquer le contenu existant
  • InfiniteData.flatten() : convertit la matrice de pages en liste plate pour SliverList
  • Création de post optimiste : setInfiniteQueryData prépose un nouveau post instantanément ; rollback en cas d'échec
  • refetch() : pull-to-refresh qui refetche toutes les pages chargées en préservant le nombre de pages
  • InfiniteFailure avec previousData : les erreurs de chargement supplémentaire affichent un bouton de réessai sans effacer le feed

Points d'architecture

La couche UI est construite sur un CustomScrollView avec deux slivers : un CupertinoSliverRefreshControl pour le pull-to-refresh et un SliverList.builder pour les éléments du feed. InfiniteQueryBuilder encapsule la vue de scroll et possède le cycle de vie de l'observer.

FeedScreen
└── CustomScrollView
    ├── CupertinoSliverRefreshControl   ← controller.refetch() au pull
    └── SliverList.builder
        ├── items = data.flatten((p) => p.posts)   ← liste plate de toutes les pages
        ├── [dernier élément]
        │   ├── hasNextPage → LoadMoreTrigger       ← déclenche controller.fetchNextPage()
        │   └── isFetchingNextPage → PageLoader     ← spinner en bas de liste
        └── InfiniteFailure avec previousData → ErrorRetryBanner

InfiniteQueryBuilder est configuré avec un curseur String. Une chaîne vide signifie « commencer depuis le début » : le serveur renvoie les posts les plus récents et un nextCursor pointant vers le contenu plus ancien.

lib/features/feed/ui/feed_screen.dart
InfiniteQueryBuilder<FeedPage, String>(
  queryKey: const ['feed'],
  fetcher: FeedApi.getFeed,
  options: InfiniteQueryOptions<FeedPage, String>(
    initialPageParam: '', // '' = posts les plus récents
    getNextPageParam: (last, _) => last.nextCursor, // posts plus anciens
    getPreviousPageParam: (first, _) => first.previousCursor, // non nul uniquement après une éviction
    maxPages: 3, // 3 pour voir l'éviction rapidement ; 8 à 10 en production
    baseOptions: const QoraOptions(
      staleTime: Duration(minutes: 2),
      retryCount: 3,
    ),
  ),
  builder: (context, state, controller) => FeedList(state: state, controller: controller),
)

maxPages borne l'InfiniteData gardé en mémoire. L'exemple utilise 3 pour que l'éviction soit visible après quelques pages ; à 20 posts par page, un feed de production utiliserait plutôt 8 à 10 (160 à 200 posts).


Détails d'implémentation

Pagination fenêtrée : défiler dans le temps

Quand l'utilisateur défile assez bas pour charger la page 4, maxPages: 3 provoque la suppression de la page 1 (les posts les plus récents) depuis InfiniteData. Le hasPreviousPage de l'état devient true. Si l'utilisateur défile à nouveau vers le haut, le SliverList le détecte et appelle controller.fetchPreviousPage(), qui refetche la page supprimée en utilisant getPreviousPageParam.

Ce mécanisme complet: suppression, détection et refetch est pourquoi la pagination par curseur est obligatoire avec maxPages. Les numéros de page entiers se briseraient ici : si 3 nouveaux posts étaient insérés pendant le scroll, refetcher "page 1" avec ?page=1&limit=20 retournerait des posts différents du premier chargement. Les curseurs ancrent à une position stable côté serveur.

lib/features/feed/ui/feed_screen.dart
void _onScroll() {
  final pos = _scrollController.position;

  // Déclencher la page suivante quand à moins de 300px du bas
  if (pos.extentAfter < 300 &&
      _latestState case InfiniteSuccess(:final hasNextPage, :final isFetchingNextPage)
          when hasNextPage && !isFetchingNextPage) {
    controller.fetchNextPage();
  }

  // Déclencher la page précédente quand à moins de 300px du haut
  // (pertinent uniquement après qu'une éviction maxPages a supprimé la page 1)
  if (pos.extentBefore < 300 &&
      _latestState case InfiniteSuccess(:final hasPreviousPage, :final isFetchingPreviousPage)
          when hasPreviousPage && !isFetchingPreviousPage) {
    controller.fetchPreviousPage();
  }
}

Création de post optimiste avec setInfiniteQueryData

Quand l'utilisateur crée un nouveau post, il doit apparaître en haut du feed instantanément, avant la réponse du serveur. Qora expose setInfiniteQueryData pour écrire directement dans le cache infini :

lib/features/feed/ui/compose_sheet.dart
Future<void> _publish(String content) async {
  final client = context.qora;
  const feedKey = ['feed'];

  // 1. Snapshot pour le rollback
  final snapshot = client.getInfiniteQueryData<FeedPage, String?>(feedKey);

  // 2. Préposition optimiste à la première page
  if (snapshot != null) {
    client.setInfiniteQueryData<FeedPage, String?>(
      feedKey,
      InfiniteData(
        pages: [
          FeedPage(
            posts: [
              Post.optimistic(
                id: 'temp-${DateTime.now().millisecondsSinceEpoch}',
                content: content,
                author: currentUser,
              ),
              ...snapshot.pages.first.posts,
            ],
            nextCursor: snapshot.pages.first.nextCursor,
            previousCursor: null, // le post optimiste n'a pas encore de curseur stable
          ),
          ...snapshot.pages.skip(1),
        ],
        pageParams: snapshot.pageParams,
      ),
    );
  }

  try {
    // 3. Déclencher la vraie mutation
    await api.createPost(content);

    // 4. Invalider : remplacer l'élément optimiste par la réponse serveur
    //    refetch() refetche toutes les pages courantes en préservant le nombre de pages
    await controller.refetch();
  } catch (error) {
    // 5. Rollback : restaurer le snapshot exact pré-mutation
    if (snapshot != null) {
      client.setInfiniteQueryData(feedKey, snapshot);
    }
    _showError(error);
  }
}

Le détail critique est l'étape 4 : appeler controller.refetch() plutôt que invalidateInfiniteQuery. refetch() refetche toutes les pages actuellement chargées (8 pages × 20 éléments = 160 posts revalidés en séquence), ce qui remplace le post optimiste avec ID temporaire par le vrai objet serveur. invalidateInfiniteQuery réinitialiserait l'état à InfiniteInitial et recommencerait depuis la page 1, perdant la position de scroll de l'utilisateur.

InfiniteFailure avec dégradation gracieuse

Les erreurs de chargement supplémentaire ne doivent jamais effacer le feed déjà chargé. InfiniteFailure préserve previousData: le dernier InfiniteData réussi, exactement pour ce cas :

lib/features/feed/ui/feed_screen.dart
case InfiniteFailure(:final error, :final previousData):
  if (previousData != null) {
    final posts = previousData.flatten((p) => p.posts);
    return SliverList.builder(
      itemCount: posts.length + 1, // +1 pour le footer d'erreur
      itemBuilder: (context, i) {
        if (i < posts.length) return PostTile(post: posts[i]);
        // Footer d'erreur tout en bas
        return _LoadMoreErrorBanner(
          error: error,
          onRetry: controller.fetchNextPage,
        );
      },
    );
  }
  // Aucune donnée du tout : écran d'erreur complet
  return SliverFillRemaining(child: ErrorScreen(error));

L'appel flatten

InfiniteData<FeedPage, String?> contient une List<FeedPage>: un élément par page chargée. flatten extrait la liste interne de chaque page et les concatène :

lib/features/feed/ui/feed_screen.dart
case InfiniteSuccess(:final data, :final hasNextPage, :final isFetchingNextPage):
  final posts = data.flatten((page) => page.posts); // List<Post>

  return SliverList.builder(
    itemCount: posts.length + 1,
    itemBuilder: (context, index) {
      if (index < posts.length) return PostTile(post: posts[index]);

      // Footer : spinner, déclencheur "charger plus" ou message fin de feed
      if (isFetchingNextPage) return const _PageLoader();
      if (hasNextPage) return _LoadMoreButton(onTap: controller.fetchNextPage);
      return const _EndOfFeedBanner();
    },
  );

Code source

examples/ex_03_infinite_scroll/
├── lib/
│   ├── main.dart                              ← configuration QoraClient + QoraScope
│   ├── features/
│   │   └── feed/
│   │       ├── data/
│   │       │   └── feed_api.dart              ← appels HTTP par curseur
│   │       ├── models/
│   │       │   ├── feed_page.dart             ← { posts, nextCursor, previousCursor }
│   │       │   └── post.dart                  ← constructeur Post.optimistic()
│   │       └── ui/
│   │           ├── feed_screen.dart           ← InfiniteQueryBuilder + logique de scroll
│   │           ├── post_tile.dart             ← widget post individuel
│   │           ├── compose_sheet.dart         ← création de post optimiste
│   │           └── widgets/
│   │               ├── page_loader.dart       ← spinner en bas de liste
│   │               ├── load_more_error.dart   ← bannière de réessai en cas d'échec de page
│   │               └── end_of_feed.dart       ← bannière "Vous êtes à jour"
└── pubspec.yaml

Voir le code source complet


Notes de performance

ProblèmeSolution
Débordement mémoire lors de longues sessionsmaxPages plafonne la fenêtre de pages ; les pages évincées sont refetchées à la demande
Jank de scroll sur de grandes listesSliverList.builder ne rend que les éléments visibles ; l'objet InfiniteData lui-même est borné
Dérive de curseur lors d'insertions serveurLa pagination par curseur ancre chaque page à une position stable côté serveur
Thundering-herd sur refetch()Les pages sont refetchées séquentiellement, pas en parallèle
Feed périmé au retour sur l'ongletstaleTime: 2 minutes déclenche un refetch() en arrière-plan au re-focus de l'onglet via FlutterLifecycleManager

Guides associés

Copyright © 2026