fuery 0.3.2 copy "fuery: ^0.3.2" to clipboard
fuery: ^0.3.2 copied to clipboard

Server state caching for Flutter: queries, infinite queries, and mutations with builder and listener widgets, retries, and background refetching.

Fuery: server state for Flutter

Fetch, cache, and keep server data fresh in Flutter.

Fuery fetches, caches, and keeps your server data fresh, with request deduplication, stale-while-revalidate caching, retries, pagination, and optimistic updates. Builder, listener, and consumer widgets, with buildWhen and listenWhen, turn queries into UI and side effects.

  • No code generation, no BuildContext required to create a query.
  • Queries are plain objects with a Stream, so blocs and cubits can use them too.
  • Refetches when the app returns to the foreground, retries failed requests, and pauses while offline.

Read the documentation →

Install #

flutter pub add fuery

Quick start #

Create a query once, for example in a State field, and build UI from it:

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

  @override
  State<TodoListScreen> createState() => _TodoListScreenState();
}

class _TodoListScreenState extends State<TodoListScreen> {
  final todos = Query.use(
    queryKey: ['todos'],
    queryFn: (_) => api.getTodos(),
  );

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

QueryResult(:final data?) matches only when there is data, so data is a non-null List<Todo> without !. Data comes first, so a list that fails to refresh stays on screen, and the error shows only when there is no data yet.

The query fetches when QueryBuilder mounts. Every widget that uses the key ['todos'] shares one cache entry and one request.

Creating a query does not fetch, so it doesn't need to be late. Use late final only when the query reads widget or other fields, for example queryKey: ['todo', widget.id].

Queries #

final todo = Query.use(
  queryKey: ['todos', id],
  queryFn: (context) => api.getTodo(id),
  staleTime: const Duration(minutes: 1),
);

Keys identify cached data. They are lists compared by value, so ['todos', 1] from two widgets is the same query. Maps inside keys are compared regardless of key order. A key can contain null, bool, num, String, enums, DateTime, lists, maps, and objects with a toJson() method.

Query data can't be null, because null means "no data yet". Use a non-nullable type like Future<User>, and throw or return an empty value when there's nothing.

Each key holds one data type. Using a key with a different type, for example setQueryData(['todos'], []) for a List<Todo> query, throws a StateError. Write setQueryData<List<Todo>>(['todos'], []) instead.

Freshness. Data is fresh for staleTime (default: zero) and stale afterwards. Stale data is still shown, and it's refetched in the background when:

  • a new widget starts using the query,
  • the app returns to the foreground,
  • the network reconnects,
  • it is invalidated.

Unused queries stay cached for gcTime (default: 5 minutes), so going back to a screen shows data instantly.

Result. The builder receives a QueryResult:

Field Meaning
status pending (no data yet), error, or success
fetchStatus fetching, paused (waiting for the network), or idle
data, error The latest data and error. data is kept when a refetch fails.
isLoading First load: pending and fetching
isRefetching Fetching while data is shown
isLoadingError / isRefetchError Failed with no data / failed with data still shown
isStale, isPlaceholderData, failureCount, dataUpdatedAt See the API docs

Options.

Option Default
enabled true false stops automatic fetching
staleTime zero infiniteDuration: fresh until invalidated. staticStaleTime: never stale or refetched automatically, even when invalidated.
gcTime 5 minutes How long unused data stays cached
retry RetryPolicy.count(3) Also .never(), .always(), .when((count, error) => ...)
retryDelay 1s, 2s, 4s, … up to 30s
refetchOnMount, refetchOnFocus, refetchOnReconnect RefetchMode.ifStale .never or .always
refetchInterval none Polls while a widget uses the query
initialData none Seeds the cache
placeholderData none Shown while pending, not cached. Pass keepPreviousData to keep the previous key's data while a new key loads.
networkMode NetworkMode.online .always ignores connectivity
structuralSharing true Keeps unchanged data identical across refetches: the whole value if nothing changed, otherwise the unchanged list items

Cancellation. Read context.signal in the query function to make it cancellable. When the last widget leaves, the fetch is aborted instead of finishing in the background:

queryFn: (context) {
  final cancelToken = CancelToken();
  context.signal.onAbort(cancelToken.cancel);
  return dio
      .get('/todos', cancelToken: cancelToken)
      .then((response) => Todo.listFromJson(response.data));
},

Widgets #

Each kind of query has a builder, a listener, and a consumer:

Rebuild UI Side effects Both
Query QueryBuilder QueryListener QueryConsumer
Infinite query InfiniteQueryBuilder InfiniteQueryListener InfiniteQueryConsumer
Mutation MutationBuilder MutationListener MutationConsumer

How they update:

  • buildWhen(previous, current) compares with the last built result.
  • listenWhen(previous, current) compares with the previous result.
  • Listeners are not called for the result the query already had when they mounted.
QueryBuilder(
  query: todos,
  buildWhen: (previous, current) => previous.isRefetching != current.isRefetching,
  builder: (context, state) =>
      state.isRefetching ? const LinearProgressIndicator() : const SizedBox(),
)

QueryListener(
  query: todos,
  listenWhen: (previous, current) => current.isRefetchError,
  listener: (context, state) => ScaffoldMessenger.of(context)
      .showSnackBar(SnackBar(content: Text('Could not refresh: ${state.error}'))),
  child: ...,
)

Mutations #

Mutations create, update, or delete server data:

final addTodo = Mutation.use(
  mutationFn: (String title) => api.addTodo(title),
  onSuccess: (todo, title, context) {
    return Fuery.instance.invalidateQueries(queryKey: ['todos']);
  },
);

addTodo.mutate('Buy milk');               // errors go to the state and callbacks
final todo = await addTodo.mutateAsync('Buy milk'); // throws on error

Returning the invalidateQueries future from onSuccess keeps the mutation pending until the list has refetched.

Optimistic updates. Update the cache in onMutate and return what you need to roll back. The returned value is passed to the other callbacks as context:

final deleteTodo = Mutation.use(
  mutationFn: (int id) => api.deleteTodo(id),
  onMutate: (id) {
    final previous = Fuery.instance.getQueryData<List<Todo>>(['todos']);
    Fuery.instance.updateQueryData<List<Todo>>(
      ['todos'],
      (todos) => todos?.where((todo) => todo.id != id).toList(),
    );
    return previous;
  },
  onError: (error, id, previous) {
    if (previous != null) Fuery.instance.setQueryData(['todos'], previous);
  },
);

Without variables, use Mutation.noParam and call mutate():

final logout = Mutation.noParam(mutationFn: () => api.logout());
logout.mutate();

Mutations don't retry unless you set retry. With a scope, mutations that share the scope id run one after another.

Infinite queries #

final posts = InfiniteQuery.use(
  queryKey: ['posts'],
  queryFn: (context) => api.getPosts(page: context.pageParam),
  initialPageParam: 1,
  getNextPageParam: (data) =>
      data.lastPage.hasMore ? data.lastPageParam + 1 : null,
);

InfiniteQueryBuilder(
  query: posts,
  builder: (context, state) => ListView(
    children: [
      for (final page in state.pages) ...page.items.map(PostTile.new),
      if (state.hasNextPage)
        TextButton(
          onPressed: state.isFetchingNextPage ? null : posts.fetchNextPage,
          child: const Text('Load more'),
        ),
    ],
  ),
)

getNextPageParam returns null when there are no more pages. Its data argument has pages, pageParams, lastPage, lastPageParam, firstPage, and firstPageParam. Add getPreviousPageParam for bidirectional lists, and maxPages to limit how many pages are kept.

Refetching an infinite query reloads every loaded page in order.

If the first page has no param, give null its type so Dart can infer it:

initialPageParam: null as String?,
getNextPageParam: (data) => data.lastPage.nextCursor,

Using with bloc #

Queries and mutations don't depend on widgets. Every observer has a stream that emits the current result first, then every change. Listening to it is what makes the query fetch.

class TodoCubit extends Cubit<TodoState> {
  TodoCubit() : super(const TodoState()) {
    _subscription = _todos.stream.listen((result) {
      emit(state.copyWith(todos: result.data, loading: result.isLoading));
    });
  }

  final _todos = Query.use(queryKey: ['todos'], queryFn: (_) => api.getTodos());
  late final StreamSubscription<QueryResult<List<Todo>>> _subscription;

  Future<void> refresh() => _todos.refetch();

  @override
  Future<void> close() {
    _subscription.cancel();
    return super.close();
  }
}

In a Bloc, use emit.forEach(todos.stream, onData: ...). The example app's stats screen shows a cubit and Fuery widgets sharing one query. If you don't use any Fuery widgets, call FueryBinding.ensureInitialized() once so queries refetch when the app resumes.

QueryClient #

Fuery.instance is the default client. Use it to read, write, and invalidate cached data:

final client = Fuery.instance;

client.invalidateQueries(queryKey: ['todos']);          // prefix match
client.invalidateQueries(queryKey: ['todos'], exact: true);
client.setQueryData(['todos', 1], todo);
client.updateQueryData<List<Todo>>(['todos'], (todos) => [...?todos, todo]);
client.getQueryData<List<Todo>>(['todos']);
client.cancelQueries(queryKey: ['todos']);
client.removeQueries(queryKey: ['todos']);

invalidateQueries marks matching queries stale and refetches the ones in use. The others refetch the next time they're used.

Fetching outside widgets. client.query returns cached data if it's fresh, and fetches otherwise. It throws on failure and doesn't retry unless you set retry:

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

final todos = await client.query(todosQuery);   // fetch, or use fresh cache
client.query(todosQuery).ignore();              // prefetch: ignore result and errors
final cached = await client.query(QueryOptions( // use any cached data
  queryKey: ['todos'],
  queryFn: (_) => api.getTodos(),
  staleTime: staticStaleTime,
));

client.infiniteQuery(infiniteQueryOptions(...)) does the same for infinite queries, and fetches pages pages when nothing is cached.

Defaults. Configure every query, or every query under a key prefix:

Fuery.instance = QueryClient(
  defaultOptions: const DefaultOptions(
    queries: QueryDefaults(staleTime: Duration(seconds: 30)),
  ),
)..mount();

Fuery.instance.setQueryDefaults(
  ['settings'],
  const QueryDefaults(staleTime: infiniteDuration),
);

FueryProvider. To give a subtree its own client, for example in widget tests, wrap it in FueryProvider and pass client: context.queryClient to the entry points:

FueryProvider(client: QueryClient(), child: const App());

late final todos = Query.use(
  queryKey: ['todos'],
  queryFn: (_) => api.getTodos(),
  client: context.queryClient,
);

context.queryClient returns Fuery.instance when there is no provider.

App lifecycle and connectivity #

Fuery widgets connect the app lifecycle automatically:

  • When the app returns to the foreground, stale queries refetch.
  • While the app is in the background, retries and polling pause.

Fuery assumes the device is online. To pause fetches while offline and refetch on reconnect, connect a connectivity source, for example connectivity_plus:

onlineManager.setEventListener((setOnline) {
  final subscription = Connectivity().onConnectivityChanged.listen((results) {
    setOnline(!results.contains(ConnectivityResult.none));
  });
  return subscription.cancel;
});

Testing #

Give each test a fresh client, and turn off retries so failures show up immediately:

testWidgets('shows todos', (tester) async {
  final client = QueryClient(
    defaultOptions: const DefaultOptions(
      queries: QueryDefaults(retry: RetryPolicy.never()),
    ),
  );
  await tester.pumpWidget(FueryProvider(client: client, child: const App()));
  // ...
  await tester.pumpWidget(const SizedBox());
  client.clear(); // cancels cache timers so the test can end
});

Acknowledgements #

Fuery's caching and refetching model is inspired by TanStack Query.

2
likes
0
points
1.58k
downloads

Documentation

Documentation

Publisher

unverified uploader

Weekly Downloads

Server state caching for Flutter: queries, infinite queries, and mutations with builder and listener widgets, retries, and background refetching.

Homepage
Repository (GitHub)
View/report issues

Topics

#react-query #state-management #server-state

License

unknown (license)

Dependencies

flutter, fuery_core

More

Packages that depend on fuery