Fuery: server state for Flutter

pub package CI coverage

Hooks for Fuery: render cached server data from inside build, in a flutter_hooks HookWidget.

Fuery is built the Flutter way. Queries are defined outside build, and widgets render them: builders for UI and listeners for side effects, in the shape of StreamBuilder. And fuery depends on nothing beyond Dart and Flutter.

fuery_hooks is for developers who prefer hooks, a style many know from web development. It is a package of its own because it depends on flutter_hooks, so only apps that choose hooks get that dependency. It renders the same queries with one call per query, and no builders:

final todosQuery = Query(
  queryKey: ['todos'],
  queryFn: (_) => api.getTodos(),
);

class TodoListScreen extends HookWidget {
  const TodoListScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final todos = useQuery(todosQuery);
    return switch (todos) {
      QueryResult(:final data?) => TodoList(data),
      QueryResult(:final error?) => Text('$error'),
      _ => const CircularProgressIndicator(),
    };
  }
}

The queries, mutations, and client are Fuery's own, so a screen written with hooks and a screen written with widgets share one cache and one request.

Read the hooks guide → · Fuery documentation →

Install

flutter pub add fuery_hooks flutter_hooks

fuery_hooks re-exports fuery, so package:fuery_hooks/fuery_hooks.dart and package:flutter_hooks/flutter_hooks.dart are the only imports you need.

Hooks

Hook Returns
useQuery(query) The latest QueryResult. refetch() is on the result.
useInfiniteQuery(query) The latest InfiniteQueryResult. fetchNextPage() is on the result.
useMutation(mutation) The latest MutationResult. mutate(...) is on the result.
useQueries(queries) The results of a list of queries of one data type, in order.
useMutationState(mutation) The state of every run of a mutation, oldest first, wherever it started, found by its mutationKey.
useQueryClient() The client the hooks use, for invalidateQueries and setData.

Each rebuilds the widget when its result changes, and needs no type arguments: todos above is a QueryResult<List<Todo>> because api.getTodos() returns a Future<List<Todo>>. They only read. Change hooks run side effects.

Changing data

final addTodo = useMutation(addTodoMutation);

ElevatedButton(
  onPressed: addTodo.isPending ? null : () => addTodo.mutate('Buy milk'),
  child: const Text('Add'),
)

A NoVariablesMutation runs with mutate(null), as from a MutationBuilder:

final logout = useMutation(logoutMutation);

TextButton(
  onPressed: () => logout.mutate(null),
  child: const Text('Log out'),
)

For a side effect of one call, pass MutateOptions to mutate. The request itself still finishes when the widget goes away. For a definition, the callbacks of its calls are dropped then. A shared observer is left alone and still runs them, so check context.mounted in them.

Reacting to changes

For navigation, snackbars, and other one-off effects, pass what a hook returns to a change hook. Its listener runs after the change, never during a build, and not for the result the widget mounts with. listenWhen compares the previous result with the new one, as on QueryListener:

final addTodo = useMutation(addTodoMutation);
useOnMutationChange(
  addTodo,
  listenWhen: (previous, current) => current.isSuccess,
  listener: (context, result) => Navigator.pop(context),
);
Hook Calls its listener after
useOnQueryChange(result, listener: ...) Each change of the result of useQuery or useInfiniteQuery.
useOnMutationChange(result, listener: ...) Each change of the result of useMutation: the runs started with it, or every run of a shared observer.
useOnMutationStateChange(mutation, listener: ...) Each change of each run of a mutation, found by its mutationKey, from any widget.

See Reacting to changes for which effect goes where.

Rules

  • Pass a definition. A Query or a Mutation can be a top-level value or be built in build: the hook keeps one observer for it and updates its options, so a new key shows in the same frame.
  • Don't call .observe() in build. A new query observer every build subscribes and fetches again, and a new mutation observer starts idle. In debug builds the hook prints a warning once per key.
  • Run side effects in a change hook. For a snackbar or navigation, use useOnQueryChange, useOnMutationChange, or useOnMutationStateChange. useEffect and useValueChanged run during the build, where those calls fail.
  • Watch the client with a memoized stream: useStream(useMemoized(() => client.watch(selector), [client])). A new stream every build rebuilds the widget on every frame.
  • The client is the one a FueryProvider above provides, or Fuery.client, and useQueryClient() returns it. A shared observer keeps the client it was created with instead.

Everything else, from keys and freshness to persistence and devtools, is Fuery's. See the documentation.

Libraries

fuery_hooks
Hooks for Fuery: useQuery, useInfiniteQuery, useMutation, useQueries, and useMutationState for flutter_hooks, and useOnQueryChange, useOnMutationChange, and useOnMutationStateChange for side effects.