Cached Query

Visit the documentation for more information.

A small collection of dart and flutter libraries inspired by tools such as SWR, RTKQuery, React Query, Urql and apollo from the React world.

Cache any the response of any asynchronous function (usually external api requests) for a fast and snappy user experience.

Accompanying packages:

  • 📱 Cached Query Flutter - Useful flutter additions, including connectivity status.
  • 💽 Cached Storage - an implementation of the CachedQuery StorageInterface using sqflite



  • Cached responses
  • Infinite list caching
  • Background fetching
  • Mutations
  • Persistent cache (flutter ios/android only, or easily create your own)
  • Can be used alongside state management options (Bloc, Provider, etc...)
  • Refetch when connection is resumed (flutter only)
  • Refetch when app comes into the foreground (flutter only)

Getting started

All you need to do is wrap an asynchronous function in a Query, give it a key and await the result.

Future<SomeData?> getCachedData() async {
  final query = Query(key: "My key", queryFn: fetchData);
  final queryState = await query.result;

CachedQuery will store the data returned from fetchData against the key so that the second time getCachedData is called it loads immediately.

The best way to use CachedQuery is to listen to the stream outputted by a query. This gives benefits like loading states, background fetches and better cache management.

Stream<QueryState<SomeData>> getCachedData() async {
  final query = Query(key: "My key", queryFn: fetchData);

Now anytime that query is fetched or mutated your UI can react to it.


A default config can be set once, so it is best to do this at the start of the app.

void main() async {
    storage: ImpementsStorageInterface(),
    config: QueryConfig(
      refetchDuration: Duration(seconds: 4),
      cacheDuration: Duration(minutes: 5),

The QueryConfig can be overridden on any individual query. The refetchDuration sets the minimum time before the queryFn is called again. The defaults to 4 seconds but if you know data is unlikely to get stale this could be increased. If you are using the api then the latest current cached data will always be emitted while waiting for data to be returned from the queryFn.

The cacheDuration is how long any data that has no listeners stays in cache. When using the Future api (query.result) the query is never has any listeners so the cacheDuration immediately starts after new data is fetched. If you are using the Stream api then the cacheDuration timer will start when the last listener is removed.

Re-fetching and Invalidation

Any Query will automatically be re-fetched if another call to the query function happens after the refetchDuration. A Query can be forced to re-fetched at anytime using Query.refetch().

After the refetchDuration is finished the query will be marked as stale. This is what causes a refetch the next time the query is requested. A query can be manually be invalidated with Query.invalidate() or a list of queries can be invalidated at once with CachedQuery.instance.invalidateCache, this is useful during Mutations.


Use an InfintiteQuery to handle caching for an infinite list. The caching works in much the same way as a Query and actually extends the QueryBase.

Infinite query takes two generic arguments, the first being the data that will be returned from the queryFn and the second is the type of the argument that will be passed to the queryFn.

final postsQuery = InfiniteQuery<List<PostModel>, int>(
  key: 'posts',
  getNextArg: (state) {
    if (state.lastPage?.isEmpty ?? false) return null;
    return state.length + 1;
  queryFn: (page) {
    return fetchPosts(endpoint: "/api/data?page=${page}");

The function getNextArg will always be called before the query function. Whatever is returned from getNextArg will be passed to the queryFn. If the return value of getNextArg is null the state on the infinite query will be set to hasReachedMax=true. This will block further page calls.

The cached data of an infinite query will always be a list of previously fetched pages. To fetch the next page use infiniteQuery.getNextPage().

Re-fetching an infinite query re-fetches each page individually starting with the first page. This is to make sure every page is upto date and there are no duplicate entries.


By its self a mutation doesn't really do anything. However, it comes with a lot of useful options for updating previously fetched queries. The simplest is the invalidateQueries prop, which takes a list query keys to invalidate once the mutation has succeeded.

final createPostMutation = Mutation<PostModel, PostModel>(
  invalidateQueries: ['posts'],
  queryFn: (post) => createPost(post),

Start the mutation by calling mutation on the Mutation object.

Note: the mutate function is also a future that will complete when the mutation succeeds or errors.

final createdPost = await createPostMutation.mutate(Post(title: "New post"));

If a key is given to a mutation it will be cached. This is useful if you need to listen to the state of the mutation anywhere in the app. For example, you could show a loading spinner in the app bar by creating a new mutation object with the same key and listening to the state stream.

Mutation<PostModel, PostModel> createPost(){
  return Mutation<PostModel, PostModel>(
    key: "createPost",
    queryFn: (post) => createPost(post),

// In the app bar

// In the create post form
createPost().mutate(Post(title: "New post"));

Because the above mutation has a key the instance of Mutation returned from createPost will always be the same and therefore the state can be observed from anywhere.

Optimistic updates

There are a few useful callbacks that enable optimistic updates.

The order of execution is: onStartMutation -> queryFn -> onSuccess. onError is called if the mutation fails and can be used to rollback changes.
Anything returned from onStartMutation will be passed to onError as the fallback.

Mutation<PostModel, PostModel> createPost() {
  return Mutation<PostModel, PostModel>(
    key: "createPost",
    invalidateQueries: ['posts'],
    queryFn: (post) async {
      final res = await Future.delayed(
        const Duration(milliseconds: 400),
        () => {
          "id": 123,
          "title": post.title,
          "userId": post.userId,
          "body": post.body,
      return PostModel.fromJson(res);
    onStartMutation: (newPost) {
      final query = CachedQuery.instance.getQuery("posts")
          as InfiniteQuery<List<PostModel>, int>;

      final fallback =;
        (old) => [
          [newPost, ...?old?.first],

      return fallback;
    onSuccess: (args, newPost) {},
    onError: (arg, error, fallback) {
        key: "posts",
        updateFn: (dynamic old) => fallback as List<List<PostModel>>,

Updating the query cache.

All the versions of updating a query require an update function. An update function passes through the current cached data and must return the new data of the same type.

There are a few ways to update the query cache.

  • If you know the key of the query you can use: CachedQuery.instance.updateQuery. Using the key will get the query from cache and call the update function on it.
  • If you have an instance of a query or infinite query, you can call update directly on it. Query.update((current) => current + 1)

CachedQuery.instance.whereQuery((query) => true | false) is a utility function that functions much like List.where. It iterates through the cache and returns all queries that satisfy the test function.

Persistent Storage

Cached query can be configured with a persistent storage option. The package **cached_storage**(Todo: add link) has built with Sqflite to cache queries to disk.

void main() async {
  storage: await CachedStorage.ensureInitialized(),
 runApp(const MyApp());

The Stored data is used to populate the initial data of a query. It is then updated anytime new data is returned from the queryFn.

Create a custom storage object using your favourite package by extending the StorageInterface.

Error handling

By default, any errors throw during the queryFn are caught by the Query. These are then added to the state and sent down the query stream. Sometimes in development it is useful to rethrow errors for better visibility. Any query (or globally) can be set to rethrow any error it catches.

    config: QueryConfig(shouldRethrow: true),

:warning: Warning: This may cause some unexpected functionality in queries, so it is recommended to only use this as a development tool.

Additional information

Inspired by fantastic packages from the react world. Read more information about them here:

Full example found in the repo here