fuery 1.1.0
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.
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.usegets 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.