
BLoC in Flutter: When It's Worth the Boilerplate
Every Flutter app I ship starts the same way: one screen, a StatefulWidget, a
few setState calls. Fast, simple, done. The mistake I made early on was
reaching for BLoC on day one of every project because it "scales better." It
does — but scale you don't need yet is just cost you're paying early. Here's the
framework I actually use to decide when BLoC earns its boilerplate and when
setState is still the right call.
The short version: if state lives on one screen, keep setState. Once the same
state has to be shared across roughly three or more screens, or it's driven by
real async streams, BLoC starts paying for itself. Everything below is how to
tell which situation you're in.
Why setState works fine — until it doesn't
setState gets an unfair reputation. For a single screen — a form, a toggle, a
counter, a screen that owns its own data — it's perfect. It's built in, there's
nothing to learn, and the state lives exactly where it's used. Reaching past it
for a one-off screen is over-engineering.
It starts to hurt at one specific moment: when a second and third screen need to
know about the same change. Say the user's login status lives in a setState
boolean on one screen. Now the profile screen and the checkout screen both need
it too. Suddenly you're passing that boolean down through constructors, lifting
state up, and chasing the same value through three widget trees. The pain isn't
setState itself — it's that setState can't answer one question: who else
needs to know this changed?
What BLoC actually solves (events, states, streams — not just "more files")
BLoC's reputation is "lots of files." What it actually gives you is a clean
separation between what happened and what's true now. You dispatch an
event (LoginRequested), the bloc does the work, and it emits a state
(AuthLoading, AuthSuccess, AuthFailure). Any screen can listen to that
state stream without owning the logic that produces it.
That's the real value: the business logic lives in one place, decoupled from the
UI, and it broadcasts state to whoever's listening. Three screens can react to
the same auth state without any of them knowing how it's computed. That's also
why BLoC pairs naturally with streams — a Stream of states is exactly the
right model when your data changes over time, not just when a user taps.
The 3-screen rule: a practical threshold for when to introduce BLoC
Here's the heuristic I use, and it's saved me from both extremes: if a piece
of state needs to be shared across three or more screens, or it's driven by a
live async source, reach for BLoC. Otherwise, setState.
Three isn't magic — it's the point where manually threading state through constructors stops being cheaper than a bloc. One or two screens, you can lift state up without much pain. At three, the wiring cost of doing it by hand overtakes the setup cost of a bloc, and a bloc also makes loading and error states predictable per screen instead of ad hoc. Match the tool to how many screens actually care about the state, not to how "proper" the architecture looks.
A real example — real-time sync in MyChurch App
The clearest case I've hit was the announcements feed in MyChurch App. Announcements had to sync in real time: an admin posts an update, and it should appear across every member's device without anyone pulling to refresh. Multiple screens cared about the same live data, and the data arrived as a stream, not a one-off fetch.
That's exactly the situation setState can't model cleanly. There's no single
screen that "owns" the announcements — they're pushed from a backend stream and
consumed in several places. A bloc listening to that stream and emitting states
let every screen subscribe to the same source of truth, with loading and empty
states handled once. If I'd tried to do that with setState, I'd have been
manually rebroadcasting the same updates into three widget trees and fighting
race conditions the whole way.
The boilerplate tax — what BLoC costs you, and when that cost isn't worth paying
BLoC isn't free, and pretending otherwise is how projects end up over-built. The
costs are real: more files (events, states, the bloc itself), more concepts for
whoever maintains the app after you, and more ceremony to add a feature. For a
simple screen, that's pure overhead — you've written three files to do what one
setState call did.
So the cost isn't worth paying when: the state is local to one screen, the app is small and likely to stay that way, or you're prototyping and speed matters more than structure. Adding BLoC "just in case" is a bet that you'll need scale you may never reach — and you pay for that bet on every feature in the meantime. It's easy to add BLoC later to the one feature that grows into needing it.
Other options worth knowing (Provider, Riverpod)
BLoC isn't the only answer, and sometimes it's not the best one:
- Provider is lighter-weight shared state. When you just need to expose some state to a subtree without the full event/state ceremony, it's often enough — good middle ground between
setStateand BLoC. - Riverpod is effectively Provider's successor: compile-safe, testable, and less boilerplate than BLoC while still handling app-wide state and async well. Many teams now reach for it by default for shared state.
My rule of thumb: setState for local, Provider/Riverpod when you want shared
state with minimal ceremony, and BLoC when you specifically want the strict,
event-driven, stream-based structure — which shines on complex flows and larger
teams where that explicitness is a feature, not a tax.
Minimal code snippet — a basic BLoC (event → state) for a login flow
Here's the whole pattern at its smallest, using flutter_bloc — an event in, a
state out:
// Events — what happened
sealed class AuthEvent {}
class LoginRequested extends AuthEvent {
LoginRequested(this.email, this.password);
final String email;
final String password;
}
// States — what's true now
sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthSuccess extends AuthState {}
class AuthFailure extends AuthState {
AuthFailure(this.message);
final String message;
}
// The bloc — maps events to states
class AuthBloc extends Bloc<AuthEvent, AuthState> {
AuthBloc(this._repo) : super(AuthInitial()) {
on<LoginRequested>((event, emit) async {
emit(AuthLoading());
try {
await _repo.login(event.email, event.password);
emit(AuthSuccess());
} catch (e) {
emit(AuthFailure(e.toString()));
}
});
}
final AuthRepository _repo;
}In the UI, a BlocBuilder<AuthBloc, AuthState> rebuilds on each state, and any
other screen can read the same AuthBloc without touching login logic. That's
the payoff — and also, for a single login screen with nothing else listening,
arguably more machinery than you need. Which is the whole point of this post.
For the full API — BlocProvider, BlocListener, cubits, and testing — the
official flutter_bloc docs are the reference.
Building a Flutter app and not sure how to structure state?
Picking the right state management early saves a painful refactor later — and over-engineering it early costs you on every feature. I make these calls on real Flutter projects and keep apps maintainable as they grow. If you want a second opinion on your architecture, or a build done right from the start, reach out, or find me on Fiverr and Upwork.


