Every Flutter developer eventually hits the wall where setState stops scaling. Screens need to share data, logic leaks into widgets, and a "simple" change breaks three features. The flutter bloc tutorial you are reading is the fix I wish someone had handed me earlier: a complete walkthrough from your first event to a production-ready BLoC architecture, with real code and the mistakes to avoid.
What BLoC Actually Is
BLoC (Business Logic Component) separates your app into three layers:
- Events — things that happened (button tapped, page loaded).
- States — what the UI should show right now.
- Bloc — the business logic that maps events to states.
Data flows in one direction: UI dispatches events → bloc processes them → bloc emits states → UI rebuilds. No bidirectional spaghetti. The flutter_bloc package gives you BlocProvider, BlocBuilder, and BlocListener to wire this into your widget tree.
Your First Bloc: A Counter (Done Right)
Add the dependency:
dependencies:
flutter_bloc: ^8.1.0
Even the classic counter is worth doing properly, because the shape of it — event, state, bloc — repeats at every scale:
// counter_event.dart
sealed class CounterEvent {}
final class CounterIncremented extends CounterEvent {}
final class CounterDecremented extends CounterEvent {}
// counter_bloc.dart
import 'package:bloc/bloc.dart';
part 'counter_event.dart';
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<CounterIncremented>((event, emit) => emit(state + 1));
on<CounterDecremented>((event, emit) => emit(state - 1));
}
}
// counter_screen.dart
class CounterScreen extends StatelessWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => CounterBloc(),
child: Scaffold(
appBar: AppBar(title: const Text('BLoC Counter')),
body: Center(
child: BlocBuilder<CounterBloc, int>(
builder: (context, count) => Text('$count',
style: Theme.of(context).textTheme.displayLarge),
),
),
floatingActionButton: Column(
mainAxisSize: MainAxisSize.min,
children: [
FloatingActionButton(
onPressed: () => context
.read<CounterBloc>()
.add(CounterIncremented()),
child: const Icon(Icons.add),
),
const SizedBox(height: 8),
FloatingActionButton(
onPressed: () => context
.read<CounterBloc>()
.add(CounterDecremented()),
child: const Icon(Icons.remove),
),
],
),
),
);
}
}
Key details: context.read<CounterBloc>() dispatches events without subscribing to rebuilds, while BlocBuilder rebuilds only when the state changes. Sealed event classes let the compiler warn you about unhandled cases.
A Real-World Bloc: Login Flow
Toy examples don't teach state design. A login flow does, because it has four genuinely different states:
sealed class LoginState {}
final class LoginInitial extends LoginState {}
final class LoginLoading extends LoginState {}
final class LoginSuccess extends LoginState {
final String userId;
LoginSuccess(this.userId);
}
final class LoginFailure extends LoginState {
final String message;
LoginFailure(this.message);
}
sealed class LoginEvent {}
final class LoginSubmitted extends LoginEvent {
final String email;
final String password;
LoginSubmitted(this.email, this.password);
}
class LoginBloc extends Bloc<LoginEvent, LoginState> {
final AuthRepository authRepository;
LoginBloc(this.authRepository) : super(LoginInitial()) {
on<LoginSubmitted>(_onSubmitted);
}
Future<void> _onSubmitted(
LoginSubmitted event,
Emitter<LoginState> emit,
) async {
emit(LoginLoading());
try {
final user = await authRepository.login(event.email, event.password);
emit(LoginSuccess(user.id));
} on AuthException catch (e) {
emit(LoginFailure(e.message));
} catch (_) {
emit(LoginFailure('Something went wrong. Please try again.'));
}
}
}
Notice the bloc talks to an AuthRepository, never to HTTP directly. BLoC is a state-management pattern, not a networking layer — keep the boundary clean.
The UI uses both builder and listener, each for its proper job:
BlocListener<LoginBloc, LoginState>(
listener: (context, state) {
if (state is LoginSuccess) {
Navigator.of(context).pushReplacementNamed('/home');
} else if (state is LoginFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: BlocBuilder<LoginBloc, LoginState>(
builder: (context, state) {
final isLoading = state is LoginLoading;
return ElevatedButton(
onPressed: isLoading
? null
: () => context.read<LoginBloc>().add(
LoginSubmitted(emailCtrl.text, passwordCtrl.text),
),
child: isLoading
? const CircularProgressIndicator()
: const Text('Sign in'),
);
},
),
);
The rule: BlocBuilder draws things, BlocListener does things (navigation, snackbars, analytics). Never navigate inside a builder.
Cubit: The Simpler Sibling
When your state is simple, a Cubit skips events and lets you call methods directly:
class ThemeCubit extends Cubit<ThemeMode> {
ThemeCubit() : super(ThemeMode.system);
void toggle() => emit(
state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark,
);
}
My working rule: reach for Cubit when state transitions are trivial (toggles, filters, selected tabs). Use Bloc when you have complex event flows, need to debounce or transform events, or want the audit trail that events provide.
Structuring BLoC in a Real App
For each feature, keep one folder:
lib/features/auth/
bloc/
login_bloc.dart
login_event.dart
login_state.dart
view/
login_screen.dart
widgets/
login_form.dart
Provide blocs at the right scope. Feature-level blocs live above the feature's navigator entry point; truly global blocs (auth session, theme) sit above MaterialApp. Two common mistakes: providing a bloc inside the widget that consumes it (recreates on every rebuild) and making everything global "just in case" (makes testing painful).
Access patterns at a glance:
| Method | Rebuilds on change? | Use for |
|---|---|---|
context.watch<Bloc>() |
Yes | Reading state inside build
|
context.read<Bloc>() |
No | Dispatching events, one-off reads |
context.select((b) => b.state.x) |
On x only |
Avoiding rebuilds on unrelated changes |
Testing Your Blocs
BLoC's payoff is testability. bloc_test lets you assert the exact state sequence for each event:
import 'package:bloc_test/bloc_test.dart';
blocTest<LoginBloc, LoginState>(
'emits [LoginLoading, LoginSuccess] when credentials are valid',
build: () => LoginBloc(FakeAuthRepository()),
act: (bloc) => bloc.add(
LoginSubmitted('a@b.com', 'secret123'),
),
expect: () => [
isA<LoginLoading>(),
isA<LoginSuccess>(),
],
);
blocTest<LoginBloc, LoginState>(
'emits [LoginLoading, LoginFailure] when credentials are invalid',
build: () => LoginBloc(FakeAuthRepository(shouldFail: true)),
act: (bloc) => bloc.add(
LoginSubmitted('a@b.com', 'wrong'),
),
expect: () => [
isA<LoginLoading>(),
isA<LoginFailure>(),
],
);
Run these against a fake repository, not your real API. Fast, deterministic, and they document the contract your UI relies on.
Bloc Concurrency: Handling Rapid Events
Real users tap fast. Two login taps in a row, or a search field firing on every keystroke, can spawn duplicate API calls or out-of-order results. The on<E> handler accepts an event transformer that controls this:
import 'package:bloc_concurrency/bloc_concurrency.dart';
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc() : super(SearchInitial()) {
// Debounce typing, cancel the in-flight request on each new keystroke.
on<SearchQueryChanged>(
_onQueryChanged,
transformer: restartable(),
);
// Ignore duplicate submits while one is already running.
on<LoginSubmitted>(
_onSubmitted,
transformer: droppable(),
);
}
}
The four transformers cover nearly every case:
| Transformer | Behavior | Use for |
|---|---|---|
sequential() (default) |
Processes events one at a time, in order | Most flows |
concurrent() |
Runs every event handler immediately | Independent events |
droppable() |
Drops new events while one is running | Submit buttons, refresh actions |
restartable() |
Cancels the running handler, starts the new one | Search-as-you-type, filters |
Combined with debounceTime from RxDart on the event stream, this is how you build search fields and pull-to-refresh flows that never double-fire.
Pitfalls to Avoid
-
Putting business logic in builders. If your
BlocBuildercomputes totals, filters lists, or validates forms, that logic is untestable and runs on every rebuild. Move it into the bloc or a selector. -
Emitting states that don't describe UI. A state like
LoginPasswordFieldFocusedis noise. States should answer one question: "what should the screen look like?" -
Forgetting to close blocs. If you create a bloc manually (
LoginBloc()in aStatefulWidget), overridedisposeand callbloc.close().BlocProviderhandles this automatically when it creates the bloc. -
One mega-bloc per app. A
AppBlocwith 40 events issetStatewith extra steps. One bloc per feature, composed together. -
Ignoring event transformers. Rapid taps firing duplicate API calls?
on<LoginSubmitted>(_onSubmitted, transformer: droppable())frombloc_concurrencysolves it in one line.
Closing
BLoC earns its reputation because the discipline compounds: events make intent explicit, states make UI predictable, and the repository boundary makes everything testable. Start with a Cubit where it's enough, graduate to full blocs for real flows, test the state sequences, and your app stays navigable at 100 screens the way it was at 10.
Building a Flutter app with serious state requirements and want it architected right from day one? Let's talk — this is the architecture I ship in production.
Originally published at uzairxdev.online
Top comments (0)