smart_bloc 0.3.2
smart_bloc: ^0.3.2 copied to clipboard
Codegen-free BLoC/Cubit layer for Flutter: sealed async state, query/mutate with stale-result protection, one-shot UI effects, and ref-counted autoDispose lifecycle.
smart_bloc #
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.watchdependency tracking).SmartComputedtakes 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 — everyBlocProvider,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
Ris independent ofT—mutate<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.mutatingistruewhile it runs, so you can disable a submit button in place;- double-taps are ignored by default (
ExecMode.droppable); switch torestartable(latest wins) orconcurrentas 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/onCloserun once per instance — put your initialload()inonCreateand it won't double-fire when the cubit is shared by two widgets or kept warm bykeepAlive.onInit/onDisposerun per widget mount/unmount (use them for analytics, focus, etc.). Andcreateonly runs on first acquisition of ascopeKey— to get a fresh instance per argument, varyscopeKey, notcreate.
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/BaseBloc→SmartCubit/SmartBloc.execute(...)splits intoquery(...)(fetch) andmutate(...)(side effect).BaseState.success/loaded/empty+BaseStatus→ sealedDataState/etc.; emptiness is a UI concern (when(empty:),AutoStateBuilder.empty).BlocManager.get/release→acquire()returning aBlocLease.BlocListeners.snackBar→UiMessageListener/ one-shot messages.
See CHANGELOG.md for the full list, including the v1 correctness bugs this release fixes.
License #
MIT © 2026 smart_bloc contributors