When a Flutter project grows past a handful of screens, the hardest part is no longer writing widgets. It’s keeping state changes predictable. The BLoC (Business Logic Component) pattern is the approach I keep coming back to, because it draws a hard line between what the UI shows and what the app does.
The core idea
A BLoC takes a stream of events in and emits a stream of states out. The widget layer never mutates data directly. It dispatches events and rebuilds when a new state arrives.
class CounterBloc extends Bloc<CounterEvent, int> {
CounterBloc() : super(0) {
on<Increment>((event, emit) => emit(state + 1));
on<Decrement>((event, emit) => emit(state - 1));
}
}The widget just listens:
BlocBuilder<CounterBloc, int>(
builder: (context, count) => Text('$count'),
);Conventions that make it scale
- One BLoC per feature, not per screen. Screens come and go. The domain concept (cart, auth, profile) is what’s stable.
- States are immutable and exhaustive. Model loading, success, and error as distinct states so the UI can’t represent an impossible combination.
- Keep side effects out of
build. Navigation and snackbars belong in aBlocListener, not insideBlocBuilder.
When not to reach for it
For a screen with a single toggle, a StatefulWidget is fine. BLoC earns its keep when state is shared, asynchronous, or needs to be tested in isolation.
That last point is the real win: because the logic lives in a plain Dart class, I can unit-test an entire feature without ever pumping a widget.