Examples

Case Study: Hooks Integration

Replace QoraBuilder widget nesting with flat, composable hook calls: useQuery, useMutation, useInfiniteQuery, and global fetch indicators in a HookWidget

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, returns QoraState<T> as a plain value; re-renders on every state change
  • useMutation<TData, TVariables>: returns a MutationHandle with mutate, mutateAsync, isPending, isError, data, reset
  • useInfiniteQuery<TData, TPageParam>: returns an InfiniteQueryHandle with pages, fetchNextPage, hasNextPage, isFetchingNextPage
  • useQueryClient: returns the nearest QoraClient for imperative operations (invalidate, setQueryData)
  • useIsFetching: global bool: true while any query has a request in flight: drives a top-level LinearProgressIndicator without coupling to individual queries
  • useIsMutating: global bool: true while 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:

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) {
    // ── 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:

lib/shared/widgets/app_progress_bar.dart
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:

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) {
          // 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

View full source code


When to use hooks vs builders

SituationPrefer
Single query, simple screenQoraBuilder or hooks
Query + mutation on same screenuseQuery + useMutation: eliminates nesting
Global loading/mutating indicatoruseIsFetching / useIsMutating: impossible to do cleanly with builders
Already using flutter_hooks in the projectqora_hooks: consistent style across the codebase
StatelessWidget / StatefulWidget codebaseQoraBuilder / QoraMutationBuilder: hooks require HookWidget

Copyright © 2026