Étude de cas : Intégration des hooks
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, retourneQoraState<T>comme valeur simple ; re-rend à chaque changement d'étatuseMutation<TData, TVariables>: retourne unMutationHandleavecmutate,mutateAsync,isPending,isError,data,resetuseInfiniteQuery<TData, TPageParam>: retourne unInfiniteQueryHandleavecpages,fetchNextPage,hasNextPage,isFetchingNextPageuseQueryClient: retourne leQoraClientle plus proche pour les opérations impératives (invalidate,setQueryData)useIsFetching:boolglobaltruetant qu'une requête a une demande en vol; pilote unLinearProgressIndicatorau niveau racine sans couplage à des requêtes individuellesuseIsMutating:boolglobaltruetant 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é :
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 :
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 :
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
Quand utiliser les hooks vs les builders
| Situation | Préférer |
|---|---|
| Requête unique, écran simple | QoraBuilder ou hooks |
| Requête + mutation sur le même écran | useQuery + useMutation : élimine l'imbrication |
| Indicateur de chargement/mutation global | useIsFetching / useIsMutating : impossible à faire proprement avec les builders |
Projet utilisant déjà flutter_hooks | qora_hooks : style cohérent dans la base de code |
Base de code StatelessWidget / StatefulWidget | QoraBuilder / QoraMutationBuilder : les hooks requièrent HookWidget |
Voir aussi
- Référence des hooks: API complète de tous les hooks
- QoraBuilder: alternative widget à
useQuery - Recette de mutations: patterns
useMutationetQoraMutationBuilder - Étude de cas défilement infini:
useInfiniteQuerydans un feed de production
Étude de cas : Conscience réseau
Les requêtes se mettent automatiquement en pause hors ligne, les mutations se mettent en file pour plus tard, et tout rejoue à la reconnexion : avec prévention du thundering-herd intégrée.
Étude de cas : Application Todo complète
Une app Flutter de niveau production combinant toutes les capacités Qora : PersistQoraClient, défilement infini avec pagination, create/toggle/delete optimistes, file de mutations hors ligne et bascule de connectivité simulée.