base_flutter_bloc
A Flutter package that eliminates repetitive boilerplate when building screens with flutter_bloc. Instead of rewriting the same loading / error / success state-handling logic on every screen, you get a ready-made state machine and a set of pre-built widgets that handle everything automatically — letting you focus on what actually matters: your UI.
The problem this package solves
A typical BLoC screen without this package looks like this:
// ❌ Before — repeated on EVERY screen
BlocConsumer<UserBloc, UserState>(
listener: (context, state) {
if (state is UserErrorState) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
builder: (context, state) {
if (state is UserLoadingState) {
return const Center(child: CircularProgressIndicator());
}
if (state is UserErrorState) {
return Center(child: Text(state.message));
}
if (state is UserSuccessState) {
return Text(state.user.name);
}
return const SizedBox.shrink();
},
);
With base_flutter_bloc, the same screen becomes:
// ✅ After — clean, no boilerplate
BaseBlocConsumer<UserBloc, User>(
builder: (context, user) => Text(user.name),
);
Loading spinner, error widget, and error notification — all handled automatically.
Features
- 🧱
BaseState<T>— a unified 4-state machine (Initial,Loading,Success,Error) that works for any data type - ⚡
BaseBlocBuilder— builds UI for all four states with a single required parameter - 🔔
BaseBlocConsumer— builds UI and shows notifications (flushbar) on error/success automatically - 👂
BaseBlocListener— listens to state changes and shows notifications without rebuilding the widget tree - 🛠
BaseBloc— base class withexecuteWithErrorHandling(try/catch elimination) and built-in retry with delay - 🟦
BaseCubit— same utilities asBaseBlocbut forCubit-based state management, includingsafeEmit() - 🔍
BaseBlocObserver— plug-and-play debug logger + single hook for crash-reporting services - ⏱
debounce/throttle— ready-made event transformers for search fields and rapid-tap protection - 📄
BasePaginationBloc— full pagination lifecycle (first load, load more, refresh) in one abstract class - 🔗
BuildContextextensions —addEvent,watchSuccessData,watchIsLoading, and more - 🎨
BaseFlutterBlocConfig— global configuration to replace the default error widget and notification with your own implementations
Getting started
Add the dependency to your pubspec.yaml:
dependencies:
base_flutter_bloc: ^<latest_version>
Then run:
flutter pub get
Usage
1. Define your BLoC
Extend BaseBloc and emit BaseState<T> subclasses. Use executeWithErrorHandling to avoid writing try/catch every time:
class UserBloc extends BaseBloc<UserEvent, BaseState<User>> {
final UserRepository _repository;
UserBloc(this._repository) : super(InitialState()) {
on<FetchUserEvent>(_onFetchUser);
}
Future<void> _onFetchUser(
FetchUserEvent event,
Emitter<BaseState<User>> emit,
) async {
emit(LoadingState());
await executeWithErrorHandling(
action: () => _repository.getUser(event.id),
onSuccess: (user) => emit(SuccessState(user)),
onError: (message) => emit(ErrorState(message)),
);
}
}
No try/catch on every handler — executeWithErrorHandling catches exceptions and routes them to onError automatically.
2. Build your screen
BaseBlocBuilder — UI only
Use it when you only need to render different widgets per state, without side effects.
BaseBlocBuilder<UserBloc, User>(
// Required: builds the widget when data is loaded
builder: (context, user) => UserCard(user: user),
// Optional overrides (all have sensible defaults):
loadingBuilder: (context) => const MyCustomSkeletonLoader(),
errorBuilder: (context, message) => MyErrorBanner(message: message),
initialBuilder: (context) => const WelcomePlaceholder(),
onRefresh: () => context.read<UserBloc>().add(FetchUserEvent()),
);
| State | Default behaviour | Override |
|---|---|---|
InitialState |
SizedBox.shrink() |
initialBuilder |
LoadingState |
CircularProgressIndicator.adaptive() |
loadingBuilder |
ErrorState |
DefaultErrorWidget with retry button |
errorBuilder |
SuccessState |
— | builder (required) |
BaseBlocConsumer — UI + notifications
Use it when you need to both render UI and react to state changes (e.g. show a notification on error).
BaseBlocConsumer<UserBloc, User>(
builder: (context, user) => UserCard(user: user),
// By default, shows an error flushbar automatically on ErrorState.
// Set to false to suppress it:
showDefaultErrorFlushbar: true,
// Optional: show a success notification when data loads:
showDefaultSuccessFlushbar: false,
// Optional: handle errors manually instead of using the default flushbar:
onError: (context, message) => showDialog(
context: context,
builder: (_) => AlertDialog(content: Text(message)),
),
onRefresh: () => context.read<UserBloc>().add(FetchUserEvent()),
);
BaseBlocListener — notifications only
Use it when the widget tree doesn't need to rebuild but you want to react to state transitions (e.g. navigate after success).
BaseBlocListener<AuthBloc, AuthData>(
showDefaultErrorFlushbar: true,
onSuccess: (context, data) => Navigator.pushReplacementNamed(context, '/home'),
child: const LoginForm(),
);
3. Emit states from your BLoC
// Show a loading spinner
emit(LoadingState());
// Pass your data to the UI
emit(SuccessState(user));
// Show the error widget + flushbar notification
emit(ErrorState('Failed to load user'));
// Show the error widget WITHOUT a flushbar notification
emit(ErrorState('Not critical error', showFlushbar: false));
// Attach the raw exception for debugging
emit(ErrorState('Something went wrong', error: exception));
4. Customize globally with BaseFlutterBlocConfig
Wrap your app (or any subtree) with BaseFlutterBlocConfig to replace the default error widget and/or notification across the entire app — without touching individual screens.
void main() {
runApp(
BaseFlutterBlocConfig(
// Replace DefaultErrorWidget with your own design
errorWidgetBuilder: (context, message, onRetry) => MyErrorWidget(
message: message,
onRetry: onRetry,
),
// Replace the flushbar with a SnackBar, toast, etc.
showFlushBarCallback: ({
required context,
message,
title,
isError = false,
messageColor,
titleColor,
durationSeconds = 3,
}) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message ?? ''),
backgroundColor: isError ? Colors.red : Colors.green,
),
);
},
child: const MyApp(),
),
);
}
If BaseFlutterBlocConfig is not provided, the package uses its built-in defaults — so existing code requires zero changes.
5. Use BaseCubit for simpler state management
When you don't need traceable events, extend BaseCubit instead of BaseBloc. It has the same executeWithErrorHandling utility plus safeEmit — which guards against emitting after the cubit is closed.
class UserCubit extends BaseCubit<BaseState<User>> {
final UserRepository _repo;
UserCubit(this._repo) : super(InitialState());
Future<void> fetchUser(String id) async {
safeEmit(LoadingState()); // safe even if user navigates away
await executeWithErrorHandling(
action: () => _repo.getUser(id),
onSuccess: (user) => safeEmit(SuccessState(user)),
onError: (msg) => safeEmit(ErrorState(msg)),
);
}
}
BaseBlocBuilder, BaseBlocConsumer, and BaseBlocListener all work with BaseCubit exactly the same way as with BaseBloc.
6. Automatic retries in executeWithErrorHandling
Both BaseBloc and BaseCubit support retrying a failed request.
await executeWithErrorHandling(
action: () => _repo.getUser(event.id),
onSuccess: (user) => emit(SuccessState(user)),
onError: (msg) => emit(ErrorState(msg)),
retries: 3, // retry up to 3 times
retryDelay: const Duration(seconds: 2), // wait 2 s between attempts
);
If all attempts fail, onError is called once with the last exception message.
7. Set up BaseBlocObserver
Register once in main() to get structured debug logs for every state transition and a global hook for crash-reporting services.
void main() {
Bloc.observer = BaseBlocObserver(
// Forward errors to your crash reporter (e.g. Firebase Crashlytics):
onErrorCallback: (bloc, error, stackTrace) {
FirebaseCrashlytics.instance.recordError(error, stackTrace);
},
logTransitions: true, // log every state change (debug mode only)
logEvents: true, // log every incoming event
logCreations: false, // log when blocs are instantiated
logClosings: false, // log when blocs are disposed
);
runApp(
BaseFlutterBlocConfig(child: const MyApp()),
);
}
All console output is gated behind kDebugMode — zero noise in release builds.
8. Debounce and throttle event transformers
Pass a transformer to Bloc.on<E>() to control how quickly events are processed.
debounce — ideal for search fields
Ignores events until the user stops firing them for the given duration. Only the last event in a burst is processed.
class SearchBloc extends BaseBloc<SearchEvent, BaseState<List<Product>>> {
SearchBloc(this._repo) : super(InitialState()) {
on<SearchQueryChanged>(
_onQueryChanged,
transformer: debounce(const Duration(milliseconds: 400)),
);
}
Future<void> _onQueryChanged(
SearchQueryChanged event,
Emitter<BaseState<List<Product>>> emit,
) async {
emit(LoadingState());
await executeWithErrorHandling(
action: () => _repo.search(event.query),
onSuccess: (results) => emit(SuccessState(results)),
onError: (msg) => emit(ErrorState(msg)),
);
}
}
throttle — ideal for buttons
Forwards the first event immediately, then ignores subsequent events for the given duration. Prevents duplicate network requests from accidental double-taps.
on<PlaceOrderEvent>(
_onPlaceOrder,
transformer: throttle(const Duration(seconds: 2)),
);
9. Paginated lists with BasePaginationBloc
Extend BasePaginationBloc<T> and implement the single required method fetchPage. Everything else — state management, first load, load more, and refresh — is handled for you.
class ProductsBloc extends BasePaginationBloc<Product> {
final ProductRepository _repo;
ProductsBloc(this._repo);
@override
int get pageSize => 20; // optional, default is 20
@override
Future<List<Product>> fetchPage(int page, int pageSize) =>
_repo.getProducts(page: page, limit: pageSize);
}
In the UI:
// Trigger the first load (e.g. in initState):
context.read<ProductsBloc>().loadFirstPage();
// Load the next page when the user scrolls to the bottom:
context.read<ProductsBloc>().loadNextPage();
// Pull-to-refresh:
context.read<ProductsBloc>().refresh();
Building the list:
BlocBuilder<ProductsBloc, PaginationState<Product>>(
builder: (context, state) {
if (state.isInitial || state.isLoading) {
return const Center(child: CircularProgressIndicator.adaptive());
}
if (state.isFirstPageError) {
return DefaultErrorWidget(message: state.error!);
}
return ListView.builder(
itemCount: state.items.length + (state.isLoadingMore ? 1 : 0),
itemBuilder: (context, index) {
if (index == state.items.length) {
// Bottom spinner while loading the next page
return const Center(child: CircularProgressIndicator.adaptive());
}
return ProductTile(product: state.items[index]);
},
);
},
)
10. BuildContext extensions
Convenience helpers that reduce common one-liners even further.
// Dispatch an event without context.read<B>().add(...):
context.addEvent<UserBloc, UserEvent>(FetchUserEvent(id: '42'));
// Read data from SuccessState without casting:
final user = context.successData<UserBloc, User>(); // null if not SuccessState
// Reactive helpers that rebuild the widget on state change:
final user = context.watchSuccessData<UserBloc, User>();
final loading = context.watchIsLoading<UserBloc, User>();
// Non-reactive reads (use inside callbacks, not build()):
final hasErr = context.hasError<UserBloc, User>();
final errMsg = context.errorMessage<UserBloc, User>();
API reference
BaseState<T>
| Class | Description |
|---|---|
InitialState<T> |
The bloc has not started loading yet |
LoadingState<T> |
An async operation is in progress |
SuccessState<T>(data) |
Data loaded successfully, holds T data |
ErrorState<T>(message) |
An error occurred, holds message, optional error, and showFlushbar flag |
BaseBloc<E, S>
Extends flutter_bloc's Bloc. Adds:
Future<void> executeWithErrorHandling<T>({
required Future<T> Function() action,
required void Function(T data) onSuccess,
void Function(String error)? onError,
int retries = 0,
Duration retryDelay = const Duration(seconds: 1),
})
BaseCubit<S>
Extends flutter_bloc's Cubit. Adds the same executeWithErrorHandling as BaseBloc, plus:
/// Emits [state] only if the cubit is not yet closed.
void safeEmit(S state)
BaseBlocObserver
| Parameter | Type | Default | Description |
|---|---|---|---|
onErrorCallback |
void Function(BlocBase, Object, StackTrace)? |
— | Called on every bloc/cubit error |
logTransitions |
bool |
true |
Log state changes in debug mode |
logEvents |
bool |
true |
Log incoming events in debug mode |
logCreations |
bool |
false |
Log when blocs are created |
logClosings |
bool |
false |
Log when blocs are closed |
debounce<E>(Duration) / throttle<E>(Duration)
Both return an EventTransformer<E> for use with Bloc.on<E>(handler, transformer: ...).
debounce— emits only afterdurationof silence; discards intermediate events.throttle— emits the first event immediately, ignores the rest forduration.
PaginationState<T>
| Field | Type | Description |
|---|---|---|
items |
List<T> |
All loaded items across all pages |
page |
int |
Last successfully loaded page index (0-based) |
hasMore |
bool |
Whether there are more pages to load |
isLoading |
bool |
true during the first page load |
isLoadingMore |
bool |
true while loading a subsequent page |
error |
String? |
Error message or null |
isInitial |
bool |
true before any load is triggered |
isFirstPageError |
bool |
true when error occurred and no items were loaded |
BasePaginationBloc<T>
Extend and implement one method:
Future<List<T>> fetchPage(int page, int pageSize);
Trigger actions via: loadFirstPage(), loadNextPage(), refresh().
Optionally override int get pageSize => 20;.
BuildContext extensions
| Method | Returns | Description |
|---|---|---|
addEvent<B, E>(event) |
void |
Shorthand for read<B>().add(event) |
currentState<B, S>() |
S |
Current state (non-reactive) |
isLoading<B, T>() |
bool |
true if state is LoadingState (non-reactive) |
hasError<B, T>() |
bool |
true if state is ErrorState (non-reactive) |
errorMessage<B, T>() |
String? |
Error message or null (non-reactive) |
successData<B, T>() |
T? |
Data from SuccessState or null (non-reactive) |
watchSuccessData<B, T>() |
T? |
Reactive — rebuilds widget on change |
watchIsLoading<B, T>() |
bool |
Reactive — rebuilds widget on change |
BaseBlocBuilder<B, T>
| Parameter | Type | Description |
|---|---|---|
builder |
Widget Function(context, T data) |
Required. Builds UI for SuccessState |
loadingBuilder |
Widget Function(context)? |
Custom loading widget |
errorBuilder |
Widget Function(context, String)? |
Custom error widget |
initialBuilder |
Widget Function(context)? |
Custom initial widget |
onRefresh |
VoidCallback? |
Passed to the default error widget as "retry" |
BaseBlocConsumer<B, T>
All parameters from BaseBlocBuilder, plus:
| Parameter | Type | Default | Description |
|---|---|---|---|
showDefaultErrorFlushbar |
bool |
true |
Show notification automatically on ErrorState |
showDefaultSuccessFlushbar |
bool |
false |
Show notification automatically on SuccessState |
onError |
void Function(context, String)? |
— | Override error handling |
onSuccess |
void Function(context, T)? |
— | Override success handling |
onMessage |
void Function(context, String)? |
— | Custom message handling |
BaseBlocListener<B, T>
| Parameter | Type | Default | Description |
|---|---|---|---|
child |
Widget |
Required | The widget subtree to wrap |
showDefaultErrorFlushbar |
bool |
true |
Show notification on ErrorState |
showDefaultSuccessFlushbar |
bool |
false |
Show notification on SuccessState |
onError |
void Function(context, String)? |
— | Override error handling |
onSuccess |
void Function(context, T)? |
— | Override success handling |