Case Study: Hooks Integration
Objective
Context: QoraBuilder introduces nested builder callbacks on screens that combine read and write operations. This structure increases indentation depth and reduces maintainability on complex views.
qora_hooks flattens this structure. useQuery and useMutation are declared at the top of build(), and the widget tree is rendered from plain values. Cache semantics and deduplication remain identical.
Key Features Demonstrated
useQuery<T>: subscribes to a query, returnsQoraState<T>as a plain value; re-renders on every state changeuseMutation<TData, TVariables>: returns aMutationHandlewithmutate,mutateAsync,isPending,isError,data,resetuseInfiniteQuery<TData, TPageParam>: returns anInfiniteQueryHandlewithpages,fetchNextPage,hasNextPage,isFetchingNextPageuseQueryClient: returns the nearestQoraClientfor imperative operations (invalidate,setQueryData)useIsFetching: globalbool:truewhile any query has a request in flight: drives a top-levelLinearProgressIndicatorwithout coupling to individual queriesuseIsMutating: globalbool:truewhile any mutation is pending: disables submit buttons app-wide- Hook composition: multiple hooks in one
build()method; they share the same underlying cache transparently
Architecture Highlights
The package is a thin adapter. Every hook calls the same QoraClient methods that QoraBuilder uses: watchQuery, watchState, watchFetchStatus: via useEffect and useState. The cache is shared: if a QoraBuilder already fetched ['users', id], useQuery gets it on the first frame from the same entry.
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
Widgets must extend HookWidget (or HookConsumerWidget for Riverpod). Standard StatelessWidget and StatefulWidget are not supported: use QoraBuilder instead.
Implementation Details
Hook composition: a complete screen in one build()
The flagship pattern: useQuery + useMutation + useQueryClient in the same build() method, no nesting, no custom State:
class ProfileScreen extends HookWidget {
final String userId;
const ProfileScreen({super.key, required this.userId});
@override
Widget build(BuildContext context) {
// ── Data ─────────────────────────────────────────────────────────────
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('Profile'),
actions: [
if (updateMutation.isError)
IconButton(
icon: const Icon(Icons.warning_amber, color: Colors.orange),
tooltip: 'Save failed: tap to retry',
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),
),
},
);
}
}
The equivalent using QoraBuilder + QoraMutationBuilder would require two nested builder callbacks and a Stack to overlay the save state: roughly 2× the line count with the same logic.
useQuery is synchronous on cache hit
The key implementation detail in use_query.dart:
// Initialise from cache: avoids a loading flash when data is already fresh.
final state = useState<QoraState<T>>(client.getQueryState<T>(key));
getQueryState is a synchronous read of the current cache entry. If another widget already fetched ['users', userId] with staleTime: 5 minutes, useQuery returns Success(data: user) on the first frame: no Initial flash, no Loading state. This is identical to how QoraBuilder uses watchState (which also reads the current value synchronously before the stream starts).
useIsFetching: global progress bar without coupling
A common UX requirement: show a thin LinearProgressIndicator at the top of the app whenever any query is loading. With hooks, this is a one-liner in any widget, with zero knowledge of specific queries:
class AppProgressBar extends HookWidget {
const AppProgressBar({super.key});
@override
Widget build(BuildContext context) {
final isFetching = useIsFetching(); // true if ANY query has a request in flight
return AnimatedOpacity(
opacity: isFetching ? 1.0 : 0.0,
duration: const Duration(milliseconds: 250),
child: const LinearProgressIndicator(minHeight: 2),
);
}
}
useIsFetching only rebuilds when the value crosses zero: not on every count change. If 5 queries are running and one finishes, the widget does not rebuild (still true). It rebuilds only when the last query finishes (true → false) or when the first starts (false → true).
useInfiniteQuery: infinite scroll without an observer
The hooks API wraps InfiniteQueryObserver into a InfiniteQueryHandle with a flat interface:
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) {
// Auto-trigger next page when the sentinel item is built
if (!query.isFetchingNextPage) query.fetchNextPage();
return const Center(child: CircularProgressIndicator());
}
return PostTile(posts[i]);
},
);
}
}
No explicit dispose is required: the observer is tied to the hook's lifecycle and cleaned up automatically when the widget unmounts.
Source Code
examples/ex_06_hooks_integration/
├── lib/
│ ├── main.dart ← QoraClient + QoraScope setup
│ ├── shared/
│ │ ├── api/json_placeholder_api.dart ← shared JSONPlaceholder client
│ │ └── widgets/
│ │ └── app_progress_bar.dart ← useIsFetching global indicator
│ └── features/
│ ├── profile/
│ │ ├── models/user.dart ← User model
│ │ └── ui/profile_screen.dart ← useQuery + useMutation composition
│ └── posts/
│ ├── models/posts_page.dart ← PostsPage model
│ └── ui/posts_screen.dart ← useInfiniteQuery
└── pubspec.yaml
When to use hooks vs builders
| Situation | Prefer |
|---|---|
| Single query, simple screen | QoraBuilder or hooks |
| Query + mutation on same screen | useQuery + useMutation: eliminates nesting |
| Global loading/mutating indicator | useIsFetching / useIsMutating: impossible to do cleanly with builders |
Already using flutter_hooks in the project | qora_hooks: consistent style across the codebase |
StatelessWidget / StatefulWidget codebase | QoraBuilder / QoraMutationBuilder: hooks require HookWidget |
Related
- Hooks Reference: full API for all hooks
- QoraBuilder: widget alternative to
useQuery - Mutations Recipe:
useMutationandQoraMutationBuilderpatterns - Infinite Scroll Case Study:
useInfiniteQueryin a production feed
Case Study: Network Awareness
Queries pause automatically when offline, mutations queue for later, and everything replays on reconnect: with thundering-herd prevention built in.
Case Study: Complete Todo App
A production-grade Flutter app combining every Qora capability: PersistQoraClient, infinite scroll with pagination, optimistic create/toggle/delete, offline mutation queue, and simulated connectivity toggling.