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

Server data caching for Flutter: cache API responses, refetch in the background, paginate, mutate with optimistic updates, and keep data across restarts.

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.

  • Drops into the app you have. Start with one screen: creating a query needs no BuildContext, and nothing else has to change.
  • Runs where your code runs. The core is pure Dart, so widgets, cubits, services, CLIs, and servers use the same query object.
  • No type arguments, no code generation. Types come from your query and mutation functions.
  • Built for real networks. Refetches when the app returns to the foreground, retries failed requests, and pauses while offline once you report connectivity.
  • Devtools in the app. Inspect the cache on a device, with no separate tooling.

Read the documentation → · Try the demo →

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

No type arguments are needed: todos is a QueryObserver<List<Todo>> because api.getTodos() returns a Future<List<Todo>>. 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 query object from Query.use gets its first listener, for example when the first widget using it mounts,
  • 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 for the app to return to the foreground to retry), 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. refetchOnReconnect defaults to .never with NetworkMode.always.
refetchInterval none Polls while a widget uses the query, counting from its latest change
refetchWhile none Polls only while this returns true for the latest result
initialData none Seeds the cache
placeholderData none Shown while pending, not cached. (previous) => previous keeps the previous key's data while a new key loads.
networkMode NetworkMode.online .always ignores connectivity. .offlineFirst runs the first attempt anyway and pauses retries while offline.
structuralSharing true Keeps unchanged data identical across refetches: the whole value if nothing changed, otherwise the unchanged list items
persist none Stores the data on the device, see Persistence

Polling until done. refetchWhile is checked on every change, so polling stops when it returns false and resumes when it returns true again:

final job = Query.use(
  queryKey: ['jobs', id],
  queryFn: (_) => api.getJob(id),
  refetchInterval: const Duration(seconds: 2),
  refetchWhile: (state) => state.data?.isDone != true,
);

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, a consumer, and a selector:

Rebuild UI Side effects Both Part of the state
Query QueryBuilder QueryListener QueryConsumer QuerySelector
Infinite query InfiniteQueryBuilder InfiniteQueryListener InfiniteQueryConsumer InfiniteQuerySelector
Mutation MutationBuilder MutationListener MutationConsumer MutationSelector

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: ...,
)

A selector builds from one value of the state and rebuilds only when that value changes. Lists, maps, and sets are compared by content:

QuerySelector(
  query: todos,
  selector: (state) => state.data?.where((todo) => todo.done).length ?? 0,
  builder: (context, doneCount) => Text('$doneCount done'),
)

Mutations #

Mutations create, update, or delete server data:

final addTodo = Mutation.use(
  mutationFn: (String title) => api.addTodo(title),
  onSuccess: (todo, title, context) {
    return Fuery.client.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. Cancel refetches of the data first, then 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) async {
    // Keep a refetch in flight from overwriting the optimistic update.
    await Fuery.client.cancelQueries(queryKey: ['todos']);
    final previous = Fuery.client.getQueryData<List<Todo>>(['todos']);
    Fuery.client.updateQueryData<List<Todo>>(
      ['todos'],
      (todos) => todos?.where((todo) => todo.id != id).toList(),
    );
    return previous;
  },
  onError: (error, id, previous) {
    if (previous != null) Fuery.client.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.isFetching ? 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,

Streaming #

streamedQuery builds a query function from a Stream that ends, such as a streamed answer. The query succeeds with the first chunk and keeps fetching until the stream is done, and combine folds each chunk into the data:

final answer = Query.use(
  queryKey: ['answer', question],
  queryFn: streamedQuery(
    stream: (context) => api.ask(question),
    initialValue: '',
    combine: (text, token) => text + token,
  ),
);

When it fetches again, refetchMode decides what happens to the data it has: StreamRefetchMode.reset (default) starts over, .append folds the new stream onto it, and .replace keeps it until the new stream is done.

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 notifications cubit and notifications screen share one query. If you don't use any Fuery widgets, call FueryBinding.ensureInitialized() once so queries refetch when the app resumes.

QueryClient #

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

final client = Fuery.client;

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.

Watching the cache. client.watch turns any value computed from the client into a Stream. It emits the current value, then a new value whenever queries or mutations change it. Watching doesn't fetch anything:

late final fetching = Fuery.client.watch((client) => client.isFetching() > 0);

StreamBuilder(
  stream: fetching,
  builder: (context, snapshot) =>
      snapshot.data == true ? const LinearProgressIndicator() : const SizedBox(),
)

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. With nothing cached it loads pages pages (default: one); otherwise it reloads the pages already cached.

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

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

Fuery.client.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.client when there is no provider.

Persistence #

Give the client a QueryStorage, and add persist to the queries worth keeping. When the app starts again, they show the stored data and refetch it if it's stale. A synchronous storage shows it on the first frame; with an asynchronous one, call await Fuery.client.restore() before runApp for the same result:

Fuery.client = QueryClient(storage: PreferencesStorage(preferences));

final todos = Query.use(
  queryKey: ['todos'],
  queryFn: (_) => api.getTodos(),
  persist: QueryPersist(
    toJson: (todos) => [for (final todo in todos) todo.toJson()],
    fromJson: (json) => [
      for (final item in json! as List) Todo.fromJson(item),
    ],
  ),
);

QueryStorage has read, write, delete, and readAll, and can be synchronous or asynchronous. Stored data expires after the client's persistMaxAge (default: one day), and version discards data in an old format. clear() deletes all stored data, for example on logout.

A mutation with persist: MutationPersist(...) and a mutationKey stores its variables while it runs, so a comment written offline is still sent after the app is closed and opened again. await Fuery.client.restore(mutations: [addCommentOptions()]) in main runs what was stored. The persistence guide has a shared_preferences storage, infinite queries, restore(), and persisted mutations.

Devtools #

FueryDevtools adds a button over your app that opens a panel with every query and mutation: their status and data, and buttons to refetch, invalidate, reset, or remove a query. It only appears in debug and profile builds.

MaterialApp(
  builder: (context, child) => FueryDevtools(child: child!),
  home: const HomeScreen(),
)

FueryDevtoolsPanel is the panel on its own, for a debug screen of your own.

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()),
    ),
  );
  Fuery.client = client;
  await tester.pumpWidget(const App());
  // ...
  await tester.pumpWidget(const SizedBox());
  client.clear(); // cancels cache timers so the test can end
});

Queries and mutations without a client: argument use Fuery.client. If your widgets pass client: context.queryClient, wrap the app in FueryProvider(client: client, child: const App()) instead.

Acknowledgements #

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

2
likes
160
points
364
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Server data caching for Flutter: cache API responses, refetch in the background, paginate, mutate with optimistic updates, and keep data across restarts.

Homepage
Repository (GitHub)
View/report issues

Topics

#state-management #server-state #cache #caching #offline

License

MIT (license)

Dependencies

flutter, fuery_core

More

Packages that depend on fuery