Exemples

Étude de cas : Intégration des hooks

Remplacer l'imbrication des builders QoraBuilder par des appels de hooks plats et composables : useQuery, useMutation, useInfiniteQuery et indicateurs de fetch globaux dans un HookWidget.

Objectif

Contexte: QoraBuilder introduit des callbacks de builder imbriqués sur les écrans qui combinent lecture et écriture. Cette structure augmente la profondeur d'indentation et réduit la maintenabilité sur les vues complexes.

qora_hooks aplatit cette structure. useQuery et useMutation sont déclarés en haut de build(), et l'arbre de widgets est rendu depuis des valeurs simples. La sémantique du cache et la déduplication restent identiques.


Fonctionnalités démontrées

  • useQuery<T>: s'abonne à une requête, retourne QoraState<T> comme valeur simple ; re-rend à chaque changement d'état
  • useMutation<TData, TVariables>: retourne un MutationHandle avec mutate, mutateAsync, isPending, isError, data, reset
  • useInfiniteQuery<TData, TPageParam>: retourne un InfiniteQueryHandle avec pages, fetchNextPage, hasNextPage, isFetchingNextPage
  • useQueryClient: retourne le QoraClient le plus proche pour les opérations impératives (invalidate, setQueryData)
  • useIsFetching: bool global true tant qu'une requête a une demande en vol; pilote un LinearProgressIndicator au niveau racine sans couplage à des requêtes individuelles
  • useIsMutating: bool global true tant qu'une mutation est en attente; désactive les boutons de soumission à l'échelle de l'app
  • Composition de hooks: plusieurs hooks dans une seule méthode build(); ils partagent le même cache sous-jacent de façon transparente

Points d'architecture

Le package est un adaptateur léger. Chaque hook appelle les mêmes méthodes QoraClient que QoraBuilder utilise watchQuery, watchState, watchFetchStatus via useEffect et useState. Le cache est partagé : si un QoraBuilder a déjà fetché ['users', id], useQuery l'obtient à la première frame depuis la même entrée.

qora_hooks/
└── lib/src/hooks/
    ├── use_query.dart          ← useState(client.getQueryState) + useEffect(watchQuery.listen)
    ├── use_mutation.dart       ← useRef(MutationController) + useState(MutationState)
    ├── use_infinite_query.dart ← useState(InfiniteQueryState) + useEffect(observer.stream.listen)
    ├── use_query_client.dart   ← QoraScope.of(context)
    ├── use_is_fetching.dart    ← client.fetchingCountStream.listen
    └── use_is_mutating.dart    ← client.mutationEvents + activeMutations

Les widgets doivent étendre HookWidget (ou HookConsumerWidget pour Riverpod). StatelessWidget et StatefulWidget standard ne sont pas supportés, utiliser QoraBuilder à la place.


Détails d'implémentation

Composition de hooks : un écran complet dans un seul build()

Le pattern emblématique : useQuery + useMutation + useQueryClient dans la même méthode build(), sans imbrication, sans State personnalisé :

lib/features/profile/ui/profile_screen.dart
class ProfileScreen extends HookWidget {
  final String userId;
  const ProfileScreen({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    // ── Données ──────────────────────────────────────────────────────────
    final client   = useQueryClient();
    final userQuery = useQuery<User>(
      key: ['users', userId],
      fetcher: () => Api.getUser(userId),
      options: const QoraOptions(staleTime: Duration(minutes: 5)),
    );
    final updateMutation = useMutation<User, UpdateUserInput>(
      mutator: Api.updateUser,
      options: MutationOptions(
        onSuccess: (user, _, __) async =>
            client.invalidate(['users', userId]),
      ),
    );

    // ── UI ───────────────────────────────────────────────────────────────
    return Scaffold(
      appBar: AppBar(
        title: const Text('Profil'),
        actions: [
          if (updateMutation.isError)
            IconButton(
              icon: const Icon(Icons.warning_amber, color: Colors.orange),
              tooltip: 'Sauvegarde échouée : appuyer pour réessayer',
              onPressed: updateMutation.reset,
            ),
        ],
      ),
      body: switch (userQuery) {
        Initial() || Loading(previousData: null) =>
            const Center(child: CircularProgressIndicator()),
        Failure(:final error, previousData: null) => ErrorScreen(error),
        _ => ProfileForm(
            user:        userQuery.dataOrNull!,
            isSaving:    updateMutation.isPending,
            saveError:   updateMutation.error,
            onSave: (input) => updateMutation.mutate(input),
          ),
      },
    );
  }
}

L'équivalent avec QoraBuilder + QoraMutationBuilder nécessiterait deux callbacks de builder imbriqués et un Stack pour superposer l'état de sauvegarde, environ 2× le nombre de lignes pour la même logique.

useQuery est synchrone sur un hit de cache

Le détail d'implémentation clé dans use_query.dart :

// Initialiser depuis le cache : évite un flash de chargement quand les données sont déjà fraîches.
final state = useState<QoraState<T>>(client.getQueryState<T>(key));

getQueryState est une lecture synchrone de l'entrée de cache courante. Si un autre widget a déjà fetché ['users', userId] avec staleTime: 5 minutes, useQuery retourne Success(data: user) à la première frame : aucun flash Initial, aucun état Loading. C'est identique à la façon dont QoraBuilder utilise watchState (qui lit aussi la valeur courante de façon synchrone avant le démarrage du stream).

useIsFetching : barre de progression globale sans couplage

Un besoin UX courant : afficher un LinearProgressIndicator fin en haut de l'app dès qu'une requête est en chargement. Avec les hooks, c'est une ligne dans n'importe quel widget, sans connaissance des requêtes spécifiques :

lib/shared/widgets/app_progress_bar.dart
class AppProgressBar extends HookWidget {
  const AppProgressBar({super.key});

  @override
  Widget build(BuildContext context) {
    final isFetching = useIsFetching(); // true si UNE QUELCONQUE requête a une demande en vol

    return AnimatedOpacity(
      opacity: isFetching ? 1.0 : 0.0,
      duration: const Duration(milliseconds: 250),
      child: const LinearProgressIndicator(minHeight: 2),
    );
  }
}

useIsFetching ne rebuilde que quand la valeur franchit zéro, pas à chaque changement de compteur. Si 5 requêtes s'exécutent et qu'une se termine, le widget ne rebuilde pas (toujours true). Il rebuilde uniquement quand la dernière requête se termine (true → false) ou quand la première commence (false → true).

useInfiniteQuery : défilement infini sans observer

L'API hooks encapsule InfiniteQueryObserver dans un InfiniteQueryHandle avec une interface plate :

lib/features/posts/ui/posts_screen.dart
class PostsScreen extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final query = useInfiniteQuery<PostsPage, String?>(
      key: const ['posts'],
      fetcher: (cursor) => Api.getPosts(cursor: cursor),
      getNextPageParam: (page) => page.nextCursor,
      initialPageParam: null,
    );

    if (query.isLoading) return const Center(child: CircularProgressIndicator());
    if (query.error != null && query.pages.isEmpty) return ErrorScreen(query.error!);

    final posts = query.pages.expand((p) => p.posts).toList();

    return ListView.builder(
      itemCount: posts.length + (query.hasNextPage ? 1 : 0),
      itemBuilder: (context, i) {
        if (i == posts.length) {
          // Déclencher automatiquement la page suivante quand l'élément sentinelle est construit
          if (!query.isFetchingNextPage) query.fetchNextPage();
          return const Center(child: CircularProgressIndicator());
        }
        return PostTile(posts[i]);
      },
    );
  }
}

Aucun dispose explicite n'est requis: l'observer est lié au cycle de vie du hook et nettoyé automatiquement quand le widget est démonté.


Code source

examples/ex_06_hooks_integration/
├── lib/
│   ├── main.dart                          ← configuration QoraClient + QoraScope
│   ├── shared/
│   │   ├── api/json_placeholder_api.dart  ← client JSONPlaceholder partagé
│   │   └── widgets/
│   │       └── app_progress_bar.dart      ← indicateur global useIsFetching
│   └── features/
│       ├── profile/
│       │   ├── models/user.dart           ← modèle User
│       │   └── ui/profile_screen.dart     ← composition useQuery + useMutation
│       └── posts/
│           ├── models/posts_page.dart     ← modèle PostsPage
│           └── ui/posts_screen.dart       ← useInfiniteQuery
└── pubspec.yaml

Voir le code source complet


Quand utiliser les hooks vs les builders

SituationPréférer
Requête unique, écran simpleQoraBuilder ou hooks
Requête + mutation sur le même écranuseQuery + useMutation : élimine l'imbrication
Indicateur de chargement/mutation globaluseIsFetching / useIsMutating : impossible à faire proprement avec les builders
Projet utilisant déjà flutter_hooksqora_hooks : style cohérent dans la base de code
Base de code StatelessWidget / StatefulWidgetQoraBuilder / QoraMutationBuilder : les hooks requièrent HookWidget

Voir aussi

Copyright © 2026