Skip to main content

flutter_cache_bloc 0.4.0 copy "flutter_cache_bloc: ^0.4.0" to clipboard
flutter_cache_bloc: ^0.4.0 copied to clipboard

Cache-first Bloc primitives with optional persistence for keyed entities, paginated collections, and synchronized multi-list UI.

flutter_cache_bloc #

Cache-first Bloc primitives, selector views, and optional encrypted persistence for Flutter apps.

flutter_cache_bloc helps feature BLoCs avoid repeating the same cache logic:

  • load one entity or many paginated collections by key;
  • show cached data immediately;
  • refresh stale data in the background;
  • prevent duplicated requests;
  • mutate one entity across several cached lists;
  • keep UI rebuilds scoped to the active key;
  • persist only the data that should survive app restarts;
  • stay independent from any infinite-scroll widget package.

The package provides:

  • EntityCacheBloc<K, T> for one entity per key;
  • PagedCacheBloc<K, T> for paginated collections per key;
  • EntityCacheView<K, T> and PagedCacheView<K, T> selector widgets;
  • HiveCachePersistenceStorage, encrypted by default;
  • MemoryCachePersistenceStorage for tests, examples, and previews;
  • CachePersistenceStorage, a small interface for custom databases.

Only successful data, pagination boundaries, hasNextPage, and freshness timestamps are persisted. Loading states and request errors are never restored as if an old request were still running.

When to use it #

Use it when the same data can appear in multiple places or when a page should start quickly from local state and then reconcile with the backend.

Good examples:

  • a post shown in home, profile, hashtag, and saved feeds;
  • a product shown in search results, category lists, and detail pages;
  • notification tabs where one item can be marked as read everywhere;
  • chat conversation lists and message histories;
  • an authenticated account/profile/settings entity;
  • pending messages or uploads that need retry after app relaunch.

Avoid durable persistence for data that should be fresh every time:

  • public feeds that can change rapidly;
  • one-off search results;
  • map results tied to the current viewport;
  • very large collections without a feature-specific cleanup strategy.

Installation #

dependencies:
  flutter_cache_bloc: ^0.4.0

The default storage uses Hive and platform secure storage internally. If an app uses a different database, provide a custom CachePersistenceStorage instead.

Important synchronization model #

flutter_cache_bloc synchronizes data inside the same Bloc instance.

If two screens read from the same provided PagedCacheBloc, calling updateItemEverywhere updates every cached list owned by that instance. If two screens create separate Bloc instances, they have separate memory caches.

Provide cache BLoCs above the routes, tabs, or shell that should share state:

MultiBlocProvider(
  providers: [
    BlocProvider(create: (_) => PostFeedBloc(repository)),
    BlocProvider(create: (_) => ProfileBloc(repository)),
  ],
  child: const App(),
);

Entity cache #

Use EntityCacheBloc when the state is centered around one entity per key.

class ProfileEntity {
  final String id;
  final String name;
  final bool isFollowing;
  final int followers;

  const ProfileEntity({
    required this.id,
    required this.name,
    required this.isFollowing,
    required this.followers,
  });

  ProfileEntity copyWith({
    bool? isFollowing,
    int? followers,
  }) {
    return ProfileEntity(
      id: id,
      name: name,
      isFollowing: isFollowing ?? this.isFollowing,
      followers: followers ?? this.followers,
    );
  }
}

class ProfileBloc extends EntityCacheBloc<String, ProfileEntity> {
  ProfileBloc(ProfileRepository repository)
      : super(
          fetch: repository.getProfile,
          getId: (profile) => profile.id,
          cacheTTL: const Duration(minutes: 10),
        );
}

Load and render one profile:

context.read<ProfileBloc>().fetchEntity(userId);

EntityCacheView<String, ProfileEntity>(
  id: userId,
  builder: (context, entity) {
    if (entity?.isLoading ?? true) {
      return const CircularProgressIndicator();
    }

    if (entity?.hasError ?? false) {
      return const Text('Could not load profile');
    }

    final profile = entity?.data;
    if (profile == null) {
      return const Text('Profile not found');
    }

    return Text(profile.name);
  },
);

Apply a local mutation without another fetch:

context.read<ProfileBloc>().updateEntity(
  userId,
  (profile) => profile.copyWith(
    isFollowing: true,
    followers: profile.followers + 1,
  ),
);

Useful entity APIs:

  • fetchEntity(id)
  • fetchEntity(id, force: true)
  • refreshEntity(id)
  • upsertEntity(entity) when getId is configured
  • upsertEntityAs(id, entity) when the cache key is external
  • updateEntity(id, updater)
  • removeEntity(id)
  • invalidateEntity(id)
  • clearCache()
  • clearCacheAndWait() for logout/account switching
  • refreshCachedEntities()
  • refreshCachedEntitiesAndWait()
  • fetchEntityAndWait(id)
  • refreshEntityAndWait(id)
  • restored
  • persistNow()
  • clearPersistedCache()
  • clearPersistedStorage()

Paged cache #

Use PagedCacheBloc when the same feature has multiple paginated datasets. Each key (K) represents a separate cached list.

enum FeedScope {
  home,
  profile,
  saved,
}

class PostEntity {
  final String id;
  final bool liked;
  final int likes;

  const PostEntity({
    required this.id,
    required this.liked,
    required this.likes,
  });

  PostEntity copyWith({
    bool? liked,
    int? likes,
  }) {
    return PostEntity(
      id: id,
      liked: liked ?? this.liked,
      likes: likes ?? this.likes,
    );
  }
}

class PostFeedBloc extends PagedCacheBloc<FeedScope, PostEntity> {
  PostFeedBloc(PostRepository repository)
      : super.offset(
          limit: 20,
          maxCache: 12,
          fetcher: (scope, skip, limit) {
            return repository.getPosts(
              scope: scope,
              skip: skip,
              limit: limit,
            );
          },
        );
}

Open and render a list:

context.read<PostFeedBloc>().openOrActivate(FeedScope.home);

PagedCacheView<FeedScope, PostEntity>(
  id: FeedScope.home,
  builder: (context, pagingState) {
    final posts = pagingState.items;

    if (pagingState.isLoading && posts.isEmpty) {
      return const CircularProgressIndicator();
    }

    return ListView.builder(
      itemCount: posts.length,
      itemBuilder: (_, index) => PostTile(posts[index]),
    );
  },
);

Pagination strategies #

PagedCacheBloc does not assume every backend paginates the same way. Pick the constructor that matches the API contract and keep that choice inside the Bloc, so repositories and UI code do not need to know how the next page key is built.

Use offset when the backend expects an absolute item offset and a page size. The first request starts at 0; each next request advances by limit, so a limit of 20 produces offsets 0, 20, 40, and so on. This is useful for APIs that expose parameters such as offset, skip, start, or any equivalent "how many items to ignore before reading" value.

class NotificationsBloc
    extends PagedCacheBloc<NotificationScope, NotificationEntity> {
  NotificationsBloc(NotificationRepository repository)
      : super.offset(
          limit: 20,
          fetcher: (scope, skip, limit) {
            return repository.getNotifications(
              scope: scope,
              skip: skip, // 0, 20, 40...
              limit: limit,
            );
          },
        );
}

Use page when the backend expects a page number and a page size. Set firstPage to the first page accepted by the API. Some APIs start at 0, while others start at 1; the Bloc will increment from that value for every next request.

class CatalogBloc extends PagedCacheBloc<CatalogScope, ProductEntity> {
  CatalogBloc(ProductRepository repository)
      : super.page(
          firstPage: 0, // page 0, 1, 2...
          limit: 20,
          fetcher: (scope, page, limit) {
            return repository.getProducts(
              scope: scope,
              page: page,
              limit: limit,
            );
          },
        );
}

For APIs that start at page 1, set firstPage: 1.

Use custom when the backend uses an integer key that is neither a classic offset nor a simple page number. The strategy receives the previously loaded keys and the amount of loaded data, so it can build the next request in a way that matches unusual contracts. Keep opaque string cursors or token-based pagination in feature-specific code unless they can be represented safely as an integer key for this Bloc.

super.custom(
  pagination: CustomPagedCachePagination(
    firstKeyBuilder: ({required limit}) => 100,
    nextKeyBuilder: ({required keys, required loadedItems, required limit}) {
      return keys.last + 100;
    },
    requestBuilder: ({required key, required limit}) {
      return PagedCachePageRequest(key: key, limit: limit, page: key);
    },
    rebuildKeysBuilder: ({required pageCount, required limit}) {
      return List.generate(pageCount, (index) => 100 + (index * 100));
    },
  ),
  fetcher: (scope, request) {
    return repository.getItems(
      scope: scope,
      pageKey: request.key,
      limit: request.limit,
    );
  },
)

The unnamed constructor remains available for compatibility and behaves like super.offset(...), but new feature BLoCs should prefer the explicit constructors.

Multi-list synchronization #

The main advantage of PagedCacheBloc is that a single mutation can update all cached lists owned by that Bloc instance.

Example: a post appears in home, profile, and saved. The user likes it in the profile tab. You do not need to manually find and patch every UI list.

context.read<PostFeedBloc>().updateFirstItemEverywhere(
  matcher: (post) => post.id == postId,
  update: (post) => post.copyWith(
    liked: true,
    likes: post.likes + 1,
  ),
);

For direct replacement:

context.read<PostFeedBloc>().updateItemEverywhere(
  item: updatedPost,
  matcher: (post) => post.id == updatedPost.id,
);

For removal:

context.read<PostFeedBloc>().removeItemEverywhere(
  matcher: (post) => post.id == deletedPostId,
);

For optimistic insertion:

context.read<PostFeedBloc>().insertItem(
  id: FeedScope.home,
  item: newPost,
);

Useful paged APIs:

  • open(id: id)
  • forceOpen(id: id)
  • openOrActivate(id)
  • fetchNext(id: id)
  • refreshActive()
  • refreshAllCached()
  • refreshCachedWhere(test)
  • insertItem(id: id, item: item)
  • insertItemEverywhere(item: item)
  • updateItem(id: id, item: item, matcher: matcher)
  • updateItemEverywhere(item: item, matcher: matcher)
  • updateFirstItemEverywhere(matcher: matcher, update: update)
  • removeItem(id: id, matcher: matcher)
  • removeItemEverywhere(matcher: matcher)
  • removeCollection(id)
  • clearCache()
  • clearCacheAndWait() for logout/profile switching
  • findItem(matcher)
  • findItems(matcher)
  • restored
  • persistNow()
  • clearPersistedCache()
  • clearPersistedStorage()

Scrolling package independence #

PagedCacheBloc stores pagination data in PagedCacheData<T>. It does not import or expose types from infinite_scroll_pagination or any other scrolling package.

The package-owned state exposes:

  • keys: requested page keys;
  • pages: loaded pages;
  • items: a flattened, non-null list;
  • hasNextPage;
  • isLoading, isFirstPageLoading, and isRefreshing;
  • error and updatedAt.

For a plain Flutter list, consume items and request the next page from your preferred scroll trigger:

PagedCacheView<FeedScope, PostEntity>(
  id: FeedScope.home,
  builder: (context, cache) {
    return ListView.builder(
      itemCount: cache.items.length,
      itemBuilder: (context, index) => PostTile(cache.items[index]),
    );
  },
);

If an app chooses a dedicated infinite-scroll package, keep the adapter in that app or in a separate integration package:

extension ExternalPagingAdapter<T> on PagedCacheData<T> {
  ExternalPagingState<int, T> toExternalPagingState() {
    return ExternalPagingState<int, T>(
      keys: keys,
      pages: pages,
      hasNextPage: hasNextPage,
      isLoading: isLoading,
      error: error,
    );
  }
}

The same PagedCacheData can be mapped to a different package without changing the feature Bloc, repository, or cache behavior.

Persistence #

Persistence is opt-in per Bloc and configurable per key. Use it for data that materially improves startup/offline behavior, and omit it for data that should always start fresh.

Data Recommended policy Reason
Authenticated profile/account Durable + background refresh Immediate startup data, then remote reconciliation
Chat/conversation list Durable + background refresh Fast inbox startup and offline visibility
Message history Durable + feature reconciliation Last messages remain visible and pending sends can retry
Pending uploads Durable + feature reconciliation Upload work can resume after relaunch
Home/profile feeds Memory-only Avoid stale or unnecessarily large local feeds
Search/map results Memory-only Query/viewport results are usually transient

Default encrypted storage #

The simplest durable setup uses HiveCachePersistenceStorage. It is encrypted by default:

final cacheStorage = HiveCachePersistenceStorage(
  namespace: 'production',
);

That is enough for most apps:

  • a Hive box is opened lazily;
  • Hive is initialized automatically for Flutter;
  • a random Hive AES key is generated when needed;
  • the key is stored in platform secure storage;
  • values are stored as the JSON strings produced by the cache BLoCs.

Disable encryption only when the cache is not sensitive or when raw inspection is explicitly needed:

final publicCacheStorage = HiveCachePersistenceStorage(
  namespace: 'public-preview-cache',
  encrypted: false,
);

Use different namespaces to isolate accounts, environments, or tenants:

final cacheStorage = HiveCachePersistenceStorage(
  namespace: 'account_$accountId',
);

Custom encryption keys #

If an app already owns a key-management policy, provide the key yourself:

final cacheStorage = HiveCachePersistenceStorage(
  namespace: 'messages',
  encryptionKeyProvider: () async {
    final stored = await myKeyStore.readCacheKey();
    return stored; // exactly 32 bytes
  },
);

Custom database #

If Hive is not the right backend for an app, implement CachePersistenceStorage. The BLoCs only need string snapshots:

class PreferencesCacheStorage implements CachePersistenceStorage {
  final SharedPreferences preferences;

  const PreferencesCacheStorage(this.preferences);

  @override
  Future<String?> read(String key) async => preferences.getString(key);

  @override
  Future<void> write(String key, String value) async {
    await preferences.setString(key, value);
  }

  @override
  Future<void> delete(String key) async {
    await preferences.remove(key);
  }

  @override
  Future<void> clear() async {
    final keys = preferences.getKeys().where((key) {
      return key.startsWith('cache.');
    }).toList();

    for (final key in keys) {
      await preferences.remove(key);
    }
  }
}

Equivalent adapters can use SQLite, Isar, Drift, ObjectBox, files, an encrypted document store, or an in-house offline database. The important part is that the feature controls the codecs and the app controls the storage.

Persist an authenticated profile #

Build codecs at the data/injection boundary, where models are available. The example keeps them inline only to stay compact:

class AccountBloc extends EntityCacheBloc<String, AccountEntity> {
  AccountBloc(
    AccountRepository repository,
    CachePersistenceStorage storage,
  ) : super(
        fetch: repository.getAccount,
        getId: (account) => account.id,
        persistence: EntityCachePersistence<String, AccountEntity>(
          storage: storage,
          storageKey: 'cache.account.v1',
          keyCodec: CacheCodec<String>(
            encode: (key) => key,
            decode: (value) => value! as String,
          ),
          valueCodec: CacheCodec<AccountEntity>(
            encode: (account) => AccountModel.fromEntity(account).toJson(),
            decode: (value) => AccountModel.fromJson(
              value! as Map<String, dynamic>,
            ),
          ),
          refreshAfterRestore: true,
          maxAge: const Duration(days: 7),
        ),
      );
}

On startup, the stored profile is emitted first. The Bloc then calls fetch in the background and replaces the entity with the remote value. If that request fails, the restored entity remains visible with the error attached to its EntityState.

Persist chats, but not posts #

Use durable persistence only on the data that benefits from it:

class ChatsBloc extends PagedCacheBloc<ChatsQuery, ChatEntity> {
  ChatsBloc(ChatRepository repository, CachePersistenceStorage storage)
      : super.offset(
          fetcher: repository.getChats,
          persistence: PagedCachePersistence<ChatsQuery, ChatEntity>(
            storage: storage,
            storageKey: 'cache.chats.v1',
            keyCodec: CacheCodec<ChatsQuery>(
              encode: (query) => query.toJson(),
              decode: (value) => ChatsQuery.fromJson(
                value! as Map<String, dynamic>,
              ),
            ),
            valueCodec: CacheCodec<ChatEntity>(
              encode: (chat) => ChatModel.fromEntity(chat).toJson(),
              decode: (value) => ChatModel.fromJson(
                value! as Map<String, dynamic>,
              ),
            ),
          ),
        );
}

class PostsBloc extends PagedCacheBloc<PostsQuery, PostEntity> {
  PostsBloc(PostRepository repository)
      : super.offset(
          fetcher: repository.getPosts,
          // No persistence: posts remain memory-only.
        );
}

Use shouldPersist when only some keys in the same Bloc should survive:

persistence: PagedCachePersistence<ChatsQuery, ChatEntity>(
  storage: storage,
  storageKey: 'cache.chats.v1',
  keyCodec: chatsQueryCodec,
  valueCodec: chatCodec,
  shouldPersist: (query) => query.accountId == authenticatedAccountId,
),

Set maxAge to discard durable entries that are too old to be useful. Leave it null when the data should remain available until logout or explicit cleanup. If a runtime persistence policy changes, call persistNow() to rewrite the snapshot immediately with the new filter result.

Reliable message and upload retries #

Durable cache preserves pending entities; it does not send messages or upload files by itself. Keep delivery state such as queued, sending, failed, and local file references in the feature entity/model. Then reconcile after restoration:

await messagesBloc.restored;

final pending = messagesBloc.findItems(
  (message) => message.deliveryStatus.isPending,
);

for (final message in pending) {
  retryCoordinator.enqueue(message);
}

Call persistNow() after recording a critical pending operation if the normal debounce window is not acceptable. The actual retry policy, connectivity checks, idempotency keys, upload continuation, and server acknowledgement remain business rules of the feature.

Cleanup #

Use clearCacheAndWait() on logout/account switching to remove memory and the current Bloc snapshot before providers are disposed:

await profileBloc.clearCacheAndWait();
await chatsBloc.clearCacheAndWait();

Use clearPersistedCache() when memory should remain visible but the durable snapshot should be deleted.

Use clearPersistedStorage() when the storage instance is scoped to a namespace that should be completely reset:

await chatsBloc.clearPersistedStorage();

At the storage level, clear() removes every snapshot owned by that storage instance:

await cacheStorage.clear();

CacheView widgets #

The view widgets are simple BlocSelector wrappers. They keep rebuilds local to one cache key.

Use them when:

  • one page renders a concrete entity id;
  • a tab renders one paginated cache key;
  • a PageView keeps multiple pages alive;
  • different sections use the same cache Bloc but should not rebuild together.

Pass a Bloc explicitly:

PagedCacheView<FeedScope, PostEntity>(
  bloc: context.read<PostFeedBloc>(),
  id: FeedScope.saved,
  builder: (context, pagingState) {
    return SavedPostsView(state: pagingState);
  },
);

Or let BlocSelector read the Bloc from context:

EntityCacheView<String, ProfileEntity>(
  id: userId,
  builder: (context, entity) {
    return ProfileHeader(profile: entity?.data);
  },
);

Error handling #

Fetchers return Either<Object, T> or Either<Object, List<T>>:

typedef FetchFn<K, T> = Future<Either<Object, T>> Function(K id);

typedef PageFetcher<K, T> =
    Future<Either<Object, List<T>>> Function(K id, int skip, int limit);

typedef PageNumberFetcher<K, T> =
    Future<Either<Object, List<T>>> Function(K id, int page, int limit);

typedef PagedCacheRequestFetcher<K, T> =
    Future<Either<Object, List<T>>> Function(
      K id,
      PagedCachePageRequest request,
    );

This keeps the package independent from any app-specific Failure hierarchy. Repositories can still convert exceptions into domain failures before the cache layer receives them.

If a fetcher throws unexpectedly, the error is stored in the relevant cache state and loading is stopped. Restored or previously loaded data remains visible when a refresh fails.

Persistence errors are reported through CachePersistence.onError. They do not put network cache state into an error state, because local storage failures should not prevent remote loading.

What this package does not do #

  • It does not prescribe repository, datasource, or Clean Architecture structure.
  • It does not render opinionated loading/error/list UI.
  • It does not synchronize separate Bloc instances automatically.
  • It does not force an infinite-scroll widget package.
  • It does not replace a transactional outbox, upload manager, idempotency keys, or feature-specific retry policy.
  • It does not decide which domain data is safe or useful to persist; that remains a feature decision.
1
likes
140
points
136
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Cache-first Bloc primitives with optional persistence for keyed entities, paginated collections, and synchronized multi-list UI.

Homepage

Topics

#flutter #bloc #cache #pagination #state-management

License

MIT (license)

Dependencies

bloc_concurrency, dartz, equatable, flutter, flutter_bloc, flutter_secure_storage, hive_ce, hive_ce_flutter

More

Packages that depend on flutter_cache_bloc