fuery 0.3.2
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.
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
BuildContextrequired 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.
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.