Flutter Integration

Hooks (flutter_hooks)

Use useQuery, useMutation, useInfiniteQuery, useIsFetching and useIsMutating with flutter_hooks for a concise, composable API.

If your project uses flutter_hooks, qora_hooks provides a hooks-based API with composable primitives for complex screens.

Installation

# You don't need to add `qora`: it's included transitively.
dependencies:
  flutter_hooks: ^0.21.0
  qora_hooks: ^ 1.3.1

useQuery

Fetches data and rebuilds the widget when the state changes. Equivalent to QoraBuilder without a nested builder callback.

class UserScreen extends HookWidget {
  final String userId;
  const UserScreen({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    final state = useQuery<User>(
      key: ['users', userId],
      fetcher: () => Api.getUser(userId),
      options: const QoraOptions(staleTime: Duration(minutes: 5)),
    );

    return switch (state) {
      Initial()                    => const SizedBox.shrink(),
      Loading(:final previousData) => previousData != null
          ? UserCard(previousData)
          : const CircularProgressIndicator(),
      Success(:final data)         => UserCard(data),
      Failure(:final error)        => ErrorView(error),
    };
  }
}

useQuery parameters

ParameterTypeDescription
keyList<Object?>Query identifier. Changes trigger refetch.
fetcherFuture<T> Function()Async function returning the data.
optionsQoraOptions?Per-query caching/retry configuration.

useQuery return value

Returns a QoraState<T>: a sealed class with four variants: Initial, Loading, Success, Failure.


useMutation

Triggers a mutation and tracks its state.

class EditProfileScreen extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final mutation = useMutation<User, UpdateUserInput>(
      mutator: (input) => Api.updateUser(input),
      options: MutationOptions(
        invalidates: [['users', currentUserId]],
      ),
    );

    return ElevatedButton(
      onPressed: mutation.isPending
          ? null
          : () => mutation.mutate(UpdateUserInput(name: 'Alice')),
      child: mutation.isPending
          ? const CircularProgressIndicator()
          : const Text('Save'),
    );
  }
}

useMutation parameters

ParameterTypeDescription
mutatorFuture<TData> Function(TVariables)Async function performing the mutation.
optionsMutationOptions?Lifecycle callbacks and retry settings.

useMutation return value

Returns a MutationHandle<TData, TVariables>:

PropertyTypeDescription
mutatevoid Function(TVariables)Fire-and-forget; errors go to state.
mutateAsyncFuture<TData?> Function(TVariables)Returns the result or null on failure.
resetvoid Function()Resets state to MutationIdle.
isIdlebooltrue when no mutation has run yet.
isPendingbooltrue while the mutation is running.
isSuccessbooltrue after successful completion.
isErrorbooltrue after a failure.
dataTData?Available when isSuccess.
errorObject?Available when isError.

useQueryClient

Returns the nearest QoraClient from the widget tree.

final client = useQueryClient();
client.invalidate(['posts']);

The widget must be wrapped in a QoraScope for this to work.


useIsFetching

Returns true while at least one query managed by the nearest QoraClient has a network request in flight. Useful for a global loading indicator at the top of the screen without coupling it to any individual query.

class AppLoadingBar extends HookWidget {
  @override
  Widget build(BuildContext context) {
    final isFetching = useIsFetching();
    return AnimatedOpacity(
      opacity: isFetching ? 1.0 : 0.0,
      duration: const Duration(milliseconds: 200),
      child: const LinearProgressIndicator(),
    );
  }
}

useIsFetching return value

bool: true when isFetchingCount > 0, false otherwise. Rebuilds the widget only when the value crosses the zero boundary.


useIsMutating

Returns true while at least one mutation managed by the nearest QoraClient is in MutationPending state. Handy for disabling a global submit button while any write operation is in progress.

class SubmitButton extends HookWidget {
  final VoidCallback onPressed;
  const SubmitButton({super.key, required this.onPressed});

  @override
  Widget build(BuildContext context) {
    final isMutating = useIsMutating();
    return FilledButton(
      onPressed: isMutating ? null : onPressed,
      child: isMutating
          ? const SizedBox.square(
              dimension: 18,
              child: CircularProgressIndicator(strokeWidth: 2),
            )
          : const Text('Save'),
    );
  }
}

useIsMutating return value

bool: true when activeMutations.length > 0, false otherwise.


useInfiniteQuery

Manages paginated data: accumulates pages and exposes a fetchNextPage function.

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,
    );

    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) {
          if (!query.isFetchingNextPage) query.fetchNextPage();
          return const Center(child: CircularProgressIndicator());
        }
        return PostTile(posts[i]);
      },
    );
  }
}

useInfiniteQuery parameters

ParameterTypeDescription
keyList<Object?>Query identifier. Changes trigger refetch.
fetcherFuture<TData> Function(TPageParam)Called with page param for each page.
getNextPageParamTPageParam? Function(TData)Returns null to signal last page.
initialPageParamTPageParamFirst page param passed to fetcher.

useInfiniteQuery return value

Returns an InfiniteQueryHandle<TData, TPageParam>:

PropertyTypeDescription
pagesList<TData>All fetched pages in order.
isLoadingbooltrue while the first page is loading.
isFetchingNextPagebooltrue while a next page is loading.
hasNextPageboolfalse when no more pages are available.
fetchNextPageFuture<void> Function()Loads next page. No-op if busy.
errorObject?Last error, or null.

Combining hooks

Hooks compose naturally. The following example fetches a user, updates their profile, and displays an inline error in a single widget without setState or StreamBuilder.

class ProfileScreen extends HookWidget {
  final String userId;
  const ProfileScreen({super.key, required this.userId});

  @override
  Widget build(BuildContext context) {
    final client = useQueryClient();
    final userQuery = useQuery<User>(
      key: ['users', userId],
      fetcher: () => Api.getUser(userId),
    );
    final updateMutation = useMutation<User, UpdateUserInput>(
      mutator: Api.updateUser,
      options: MutationOptions(
        invalidates: [['users', userId]],
      ),
    );

    return switch (userQuery) {
      Loading()             => const CircularProgressIndicator(),
      Failure(:final error) => ErrorView(error),
      Success(:final data)  => ProfileForm(
        user: data,
        isSaving: updateMutation.isPending,
        saveError: updateMutation.error,
        onSave: (input) => updateMutation.mutate(input),
      ),
      _ => const SizedBox.shrink(),
    };
  }
}

qora_hooks requires your widget to extend HookWidget (or use HookConsumerWidget if you are combining with Riverpod). Standard StatelessWidget and StatefulWidget are not supported: use QoraBuilder and QoraMutationBuilder instead.
Copyright © 2026