smart_bloc

License: MIT style: flutter_lints

A powerful, codegen-free BLoC/Cubit layer for Flutter. It keeps the parts of BLoC that scale (explicit state, testability, flutter_bloc tooling) and adds the ergonomics people reach for Riverpod's generator to get — typed async state, auto-dispose, family-style scoping, safe mutations — without build_runner.

class PostCubit extends SmartCubit<List<Post>> {
  PostCubit(this._repo);
  final PostRepository _repo;

  Future<void> load() => query(action: _repo.getAll);

  Future<void> delete(int id) => mutate<int>(
        action: () => _repo.delete(id),
        apply: (posts, id) => posts.where((p) => p.id != id).toList(),
        successMessage: 'Post deleted',
      );
}
AutoStateBuilder<PostCubit, List<Post>>(
  create: () => PostCubit(repo),
  onCreate: (c) => c.load(),                  // once per instance (not per rebuild)
  listenMessages: true,                       // one-shot snackbars
  data: (context, posts) => PostListView(posts),
  // loading / error / empty come from SmartBlocDefaults — override per-call if needed
)

That is a full screen: loading spinner, error view with retry, empty state, delete-with-undo-safe list mutation, and a "Post deleted" snackbar — no state classes, no build_runner, no manual dispose.


Why this over Riverpod + codegen?

smart_bloc riverpod + riverpod_generator
Build step None — pure Dart/Flutter build_runner watch always running
Async state BaseState<T> sealed union, data is non-nullable in DataState AsyncValue<T>
Auto-dispose BlocManager leases (ref-counted) + keepAlive @riverpod autoDispose / keepAlive
Scoped/param instances BlocFamily / scopeKey (no codegen) family (codegen)
Derived state SmartComputed (explicit sources) derived @riverpod (implicit ref.watch)
Pagination SmartPaginatedCubit (loadMore/hasMore) manual AsyncNotifier
Mutations first-class mutate() — data stays on screen, errors are one-shot manual AsyncNotifier + ref.listen
One-shot effects UiMessage / Effect streams (snackbars/nav that don't replay) manual ref.listen plumbing
Testing BlocManager.override + bloc_test ProviderContainer overrides
Tooling full flutter_bloc + bloc devtools/observer Riverpod devtools

The one thing smart_bloc deliberately does not copy is Riverpod's implicit reactive graph (automatic ref.watch dependency tracking). SmartComputed takes explicit sources instead — no magic, no build step.

If your team already thinks in blocs, or you simply don't want a code generator in the loop, smart_bloc gives you the modern feature set on top of the mature flutter_bloc runtime.

It is a layer on flutter_bloc, not a replacement — every BlocProvider, context.read, observer and devtool keeps working.


Installation

dependencies:
  smart_bloc: ^0.2.0
import 'package:smart_bloc/smart_bloc.dart'; // re-exports flutter_bloc too

Core concepts

BaseState<T> — a sealed union, not a status enum

Four variants; the type system guarantees data is present when it says it is:

sealed class BaseState<T> {}
class InitialState<T> // nothing yet
class LoadingState<T> // .previousData (keep stale data during refresh)
class DataState<T>    // .data is a NON-NULLABLE T
class ErrorState<T>   // .failure + .previousData

Match with native Dart 3 patterns (exhaustive, no data as T casts):

switch (state) {
  InitialState()                    => const SizedBox(),
  LoadingState(:final previousData) => previousData == null
      ? const CircularProgressIndicator()
      : StaleList(previousData),
  DataState(:final data)            => PostList(data),   // data is List<Post>, never null
  ErrorState(:final failure)        => ErrorView(failure.message),
}

…or the when/maybeWhen sugar:

state.when(
  initial: () => const SizedBox(),
  loading: (previous) => const Spinner(),
  data:    (posts) => PostList(posts),
  error:   (failure, previous) => ErrorView(failure.message),
  empty:   () => const Text('No posts'), // optional: intercepts empty List/Map
);

Handy accessors: state.dataOrNull, hasData, isLoading, isRefreshing (loading with previous data), isMutating, failureOrNull, errorMessage.

SmartCubit<T> — query & mutate

query produces the screen's data. Each call supersedes the previous one and a stale response that finishes late is dropped, so fast-typing search boxes and rapid refreshes never flicker old data back:

Future<void> search(String term) =>
    query(action: () => repo.search(term)); // latest wins automatically

mutate<R> runs a side effect. It is deliberately different from a query:

  • its result type R is independent of Tmutate<void> needs no casts and cannot corrupt the data state (this was a real crash in v1);
  • on failure the current data stays on screen; the error is delivered as a one-shot message, not by replacing the state;
  • state.mutating is true while it runs, so you can disable a submit button in place;
  • double-taps are ignored by default (ExecMode.droppable); switch to restartable (latest wins) or concurrent as needed.
Future<void> save(Draft draft) => mutate<Post>(
      action: () => repo.save(draft),
      apply: (posts, saved) => [...posts, saved], // update data from the result
      successMessage: 'Saved',
      errorMessage: (f) => f.isNetwork ? 'You are offline' : f.message,
    );

Other helpers: refresh() (re-run the last query), setData, updateData (optimistic), reset, cancelPending, and listenTo(otherBloc, ...) for auto-cancelled cross-bloc composition.

One-shot effects — snackbars & navigation that don't replay

State-based snackbars have two classic bugs: the same error twice shows only one snackbar (equal states aren't re-emitted), and rebuilding a screen replays an old "Saved!". smart_bloc separates events from state:

// In a cubit: mutate(successMessage: ...) emits automatically, or do it manually:
emitSuccessMessage('Copied to clipboard');
emitErrorMessage('Something went wrong');
// In the tree — identical consecutive messages both show:
UiMessageListener<PostCubit>(child: PostListPage())
// AutoStateBuilder(listenMessages: true) wires this for you.

For navigation/dialogs use a typed effect channel:

class AuthCubit extends SmartCubit<User> with BlocEffects<BaseState<User>, AuthEffect> {
  Future<void> signIn() => mutate(action: repo.signIn, onSuccess: (_) => emitEffect(GoHome()));
}

EffectListener<AuthCubit, AuthEffect>(
  onEffect: (context, e) => switch (e) { GoHome() => context.go('/home') },
  child: const LoginForm(),
)

BlocManager — auto-dispose & family, no codegen

Widgets lease instances; the instance closes when the last lease is released. scopeKey gives you independent instances of one type (Riverpod's family):

AutoBlocProvider<AuthCubit>(create: () => AuthCubit(repo), child: AppShell())

AutoStateBuilder<TabCubit, TabData>(
  scopeKey: 'tab-$id',                 // one cubit per tab, auto-closed
  create: () => TabCubit(id),
  onCreate: (c) => c.load(),
  data: (context, data) => TabView(data),
)

Lifecycle callbacks come in two tiers. onCreate / onClose run once per instance — put your initial load() in onCreate and it won't double-fire when the cubit is shared by two widgets or kept warm by keepAlive. onInit / onDispose run per widget mount/unmount (use them for analytics, focus, etc.). And create only runs on first acquisition of a scopeKey — to get a fresh instance per argument, vary scopeKey, not create.

Manual leases when you need them (idempotent, generation-safe — double-release and external close can't corrupt the ref-count, a v1 bug):

final lease = BlocManager.acquire<CartCubit>(create: CartCubit.new);
lease.bloc.addItem(item);
lease.release();

Register a DI factory once and drop the inline create:

BlocManager.setFactory(<T extends BlocBase<Object?>>() => getIt<T>());
final auth = BlocManager.acquire<AuthCubit>(); // from GetIt

keepAlive keeps an instance warm after its last lease drops, so quick back-navigation reuses it instead of re-fetching:

AutoStateBuilder<FeedCubit, List<Post>>(
  create: FeedCubit.new,
  // acquire is called internally; pass keepAlive via BlocManager for manual leases:
)
BlocManager.acquire<FeedCubit>(create: FeedCubit.new, keepAlive: const Duration(minutes: 5));

BlocFamily — typed, parameterized instances (Riverpod's family):

final productDetail = BlocFamily<ProductCubit, String>((id) => ProductCubit(id));
final lease = productDetail.acquire(productId); // one instance per id, auto-disposed

Testing — inject fakes with override:

setUp(() => BlocManager.override<AuthCubit>(() => FakeAuthCubit()));
tearDown(BlocManager.clearOverrides);

SmartComputed — derived state

Recomputes from explicit source blocs whenever any of them changes (equal results aren't re-emitted):

class CartTotalCubit extends SmartComputed<int> {
  CartTotalCubit(CartCubit cart)
      : super(sources: [cart], compute: () => cart.state.dataOrNull?.total ?? 0);
}
// or inline:
final total = SmartComputed<int>(sources: [cart], compute: () => ...);

SmartPaginatedCubit — infinite scroll

loadFirst loads page one; loadMore appends and tracks hasMore. A load-more failure keeps the list and surfaces a one-shot message; footer spinner reads state.isMutating.

class FeedCubit extends SmartPaginatedCubit<Post> {
  FeedCubit(this._repo);
  final FeedRepository _repo;
  int _page = 1;

  Future<void> load()  { _page = 1; return loadFirst(() => _repo.page(_page)); }
  Future<void> more()  => loadMore(() => _repo.page(++_page));
}

// repository returns a Page:
Future<Result<Page<Post>>> page(int p) =>
    Result.guard(() async => Page(await _api.feed(p), hasMore: p < lastPage));

Result<T> & Failure

Type-safe errors without exceptions. Result.guard turns a throwing call into a Result (like AsyncValue.guard):

Future<Result<User>> getUser(String id) => Result.guard(() => api.fetchUser(id));

result.fold(onSuccess: (u) => ..., onFailure: (f) => ...);
result.map(...).flatMap(...).getOrElse(() => User.guest());

Structured Failure hierarchy (NetworkFailure, TimeoutFailure, ServerFailure, AuthFailure, DataFailure, UnknownFailure) with helpers like failure.isRetryable, failure.needsReLogin, failure.retryAfter. Translate your own exceptions globally:

SmartBlocConfig.failureMapper = (error, stack) => switch (error) {
  DioException(:final response?) =>
    ServerFailure(message: 'HTTP ${response.statusCode}', statusCode: response.statusCode),
  Failure() => error,
  _ => UnknownFailure.from(error, stack),
};
SmartBlocConfig.onUncaughtError =
    (error, stack) => FirebaseCrashlytics.instance.recordError(error, stack);

Widgets at a glance

Widget Use
AutoStateBuilder<C, T> Full screen for a SmartCubit — data UI + default loading/error/empty/refresh
AutoBlocBuilder<B, S> Lease + BlocBuilder, bloc passed to the builder
AutoBlocConsumer / AutoBlocListener / AutoBlocSelector Lease + the matching flutter_bloc widget
AutoBlocProvider<B> Lease + provide to descendants, no UI
MultiAutoBlocProvider Nest providers without deep indentation
UiMessageListener<B> / EffectListener<B, E> Present one-shot messages / typed effects

Customize every default once via SmartBlocDefaults (loading, error, empty, initial, showMessage).


Event-based SmartBloc

Prefer events? SmartBloc<E, T> is fully generic (state is BaseState<T>, not Object?) with the same query/mutate:

class PostBloc extends SmartBloc<PostEvent, List<Post>> {
  PostBloc(this._repo) {
    on<LoadPosts>((e, emit) => query(emit, action: _repo.getAll));
    on<DeletePost>((e, emit) => mutate<int>(
          emit,
          action: () => _repo.delete(e.id),
          apply: (posts, id) => posts.where((p) => p.id != id).toList(),
          successMessage: 'Deleted',
        ));
  }
  final PostRepository _repo;
}

Migrating from 0.1.x

  • BaseCubit/BaseBlocSmartCubit/SmartBloc.
  • execute(...) splits into query(...) (fetch) and mutate(...) (side effect).
  • BaseState.success/loaded/empty + BaseStatus → sealed DataState/etc.; emptiness is a UI concern (when(empty:), AutoStateBuilder.empty).
  • BlocManager.get/releaseacquire() returning a BlocLease.
  • BlocListeners.snackBarUiMessageListener / one-shot messages.

See CHANGELOG.md for the full list, including the v1 correctness bugs this release fixes.


License

MIT © 2026 smart_bloc contributors

Libraries

smart_bloc
A powerful, codegen-free BLoC/Cubit layer for Flutter.