Every state management tutorial uses the same examples. A counter. A to-do list. Maybe a login form. The code is clean. The patterns are clear. You feel ready.
Then you ship a real app. Twenty screens become forty. Forty become sixty. Features that were isolated start sharing state. Teams grow. Performance degrades in ways you didn't predict. The patterns that worked at ten screens start cracking at fifty.
This post is about what changes — the problems that only appear at scale, the patterns that emerge to solve them, and the architectural decisions that seem optional early but become load-bearing later.
The Symptoms of Scale
You know you've hit a scale inflection point when:
- New features take longer than expected — not because they're complex, but because understanding what state already exists and how it connects takes half the work
- Bug fixes cause new bugs — changing state in one feature breaks behavior in another, because they share state through paths nobody documented
- Performance degrades gradually — not a single bottleneck, but death by a thousand unnecessary rebuilds
- Onboarding takes weeks — new developers can't reason about the app's state flow without extensive documentation or tribal knowledge
These symptoms aren't caused by choosing the wrong tool. They're caused by patterns that worked at small scale and didn't evolve as the app grew.
Problem 1: State Spaghetti
At five screens, state relationships are obvious. The auth state feeds into the profile screen. The cart state feeds into the checkout. Easy to hold in your head.
At fifty screens, those relationships form a web. The auth state feeds into profile, but also into the product listing (to show favorites), the chat feature (to authenticate WebSocket connections), the notification system (to filter by user preferences), and the admin panel (to check permissions). The cart state is read by checkout, but also by the product detail screen (to show "already in cart"), the home screen (to show the cart badge), and the analytics system.
Nobody planned this web. It grew feature by feature, each one reaching into existing state because it was there and it was convenient.
The Pattern: State Boundaries
Draw explicit boundaries around state domains. Each domain owns its state and exposes only what other domains need — through defined interfaces, not direct access.
// DON'T: Every feature reaches into AuthBloc directly
context.read<AuthBloc>().state.user.preferences.notifications.isEnabled
// DO: Auth domain exposes only what others need
abstract class AuthFacade {
Stream<AuthStatus> get authStatus;
User? get currentUser;
bool hasPermission(Permission permission);
}
class AuthFacadeImpl implements AuthFacade {
final AuthBloc _authBloc;
AuthFacadeImpl(this._authBloc);
@override
Stream<AuthStatus> get authStatus =>
_authBloc.stream.map((state) => state.status).distinct();
@override
User? get currentUser => switch (_authBloc.state) {
AuthAuthenticated(user: final u) => u,
_ => null,
};
@override
bool hasPermission(Permission permission) {
final user = currentUser;
if (user == null) return false;
return user.permissions.contains(permission);
}
}Other features depend on AuthFacade, not AuthBloc. The auth domain can change its internal implementation — switch from BLoC to Riverpod, restructure its states, add new events — without breaking any consumer.
This is the same principle as bounded contexts in domain-driven design, applied to frontend state. (See DDD Bounded Contexts for the backend perspective.)
With Riverpod
// Auth domain — internal
@riverpod
class AuthNotifier extends _$AuthNotifier {
// Full implementation — internal detail
}
// Auth domain — public facade
@riverpod
User? currentUser(CurrentUserRef ref) {
final auth = ref.watch(authNotifierProvider);
return switch (auth) {
AuthAuthenticated(user: final u) => u,
_ => null,
};
}
@riverpod
bool hasPermission(HasPermissionRef ref, Permission permission) {
final user = ref.watch(currentUserProvider);
return user?.permissions.contains(permission) ?? false;
}
// Other features use the facade providers, not the internal notifierThe boundary is the set of public providers. The internal implementation is hidden.
Problem 2: The God BLoC
It starts innocently. The AppBloc manages auth state, app initialization, connectivity status, and theme. It makes sense — these are app-level concerns. Then someone adds notification state because "it's app-level." Then feature flags. Then deep link handling. Then locale preferences.
Now the AppBloc has thirty events, fifteen states, and nobody understands the full state machine.
The Pattern: Single Responsibility Per State Unit
Each BLoC/provider/controller should manage one concept. If you can't describe what it manages in one sentence without using "and," split it.
// DON'T
class AppBloc extends Bloc<AppEvent, AppState> {
// Manages auth AND connectivity AND theme AND notifications AND locale AND...
}
// DO
class AuthBloc extends Bloc<AuthEvent, AuthState> { /* auth only */ }
class ConnectivityBloc extends Bloc<ConnectivityEvent, ConnectivityState> { /* connectivity only */ }
class ThemeBloc extends Bloc<ThemeEvent, ThemeState> { /* theme only */ }
class NotificationBloc extends Bloc<NotificationEvent, NotificationState> { /* notifications only */ }If these blocs need to coordinate (e.g., notification behavior depends on auth state), use explicit communication — not by stuffing them into one BLoC:
class NotificationBloc extends Bloc<NotificationEvent, NotificationState> {
late final StreamSubscription _authSubscription;
NotificationBloc({required AuthFacade authFacade}) : super(NotificationInitial()) {
_authSubscription = authFacade.authStatus.listen((status) {
if (status == AuthStatus.unauthenticated) {
add(NotificationsCleared());
}
});
on<NotificationsCleared>(_onCleared);
// ...
}
@override
Future<void> close() {
_authSubscription.cancel();
return super.close();
}
}Each BLoC has one job. Coordination is explicit and traceable.
Problem 3: Rebuild Avalanche
At scale, a single state change can trigger rebuilds across dozens of widgets. The auth state changes → thirty widgets that depend on it rebuild → some of those trigger async operations → more rebuilds.
The Pattern: Selective Listening
BLoC approach: buildWhen to control which state changes trigger rebuilds.
// DON'T: Rebuilds on every auth state change
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) => Text(state.user?.name ?? ''),
)
// DO: Only rebuilds when the user's name actually changes
BlocBuilder<AuthBloc, AuthState>(
buildWhen: (previous, current) =>
previous.user?.name != current.user?.name,
builder: (context, state) => Text(state.user?.name ?? ''),
)Riverpod approach: select to watch specific fields.
// DON'T: Rebuilds on any auth state change
final auth = ref.watch(authProvider);
// DO: Only rebuilds when name changes
final name = ref.watch(authProvider.select((a) => a.user?.name));Signals approach: Fine-grained by default — each signal is its own subscription.
At scale, the difference between "rebuild when anything changes" and "rebuild when this specific value changes" is the difference between a smooth app and a janky one. Apply selective listening everywhere, not just where you notice performance problems.
The Pattern: Computed/Derived State
Instead of having widgets compute derived values during build (which triggers on every rebuild), compute them once and cache the result:
// DON'T: Every widget that needs the cart total computes it
Widget build(BuildContext context) {
final cart = context.watch<CartBloc>().state;
final total = cart.items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
// ...
}
// DO: Compute once, share the result
@riverpod
double cartTotal(CartTotalRef ref) {
final items = ref.watch(cartProvider.select((c) => c.items));
return items.fold(0.0, (sum, item) => sum + item.price * item.quantity);
}
// Or with BLoC: include computed values in the state
class CartState {
final List<CartItem> items;
final double total; // Computed when state is created
final int itemCount;
CartState({required this.items})
: total = items.fold(0.0, (sum, item) => sum + item.price * item.quantity),
itemCount = items.fold(0, (sum, item) => sum + item.quantity);
}Problem 4: Testing at Scale
At ten screens, you can write integration tests that set up the full app state. At fifty screens, setting up the full app state for one test requires initializing thirty BLoCs with specific configurations.
The Pattern: Isolated Feature Testing
Structure your code so each feature can be tested with only its direct dependencies, not the entire app's state.
// Feature tests only need the feature's BLoC and its facade dependencies
void main() {
group('CheckoutBloc', () {
late CheckoutBloc bloc;
late MockCartFacade mockCart;
late MockPaymentFacade mockPayment;
setUp(() {
mockCart = MockCartFacade();
mockPayment = MockPaymentFacade();
// Only inject what checkout actually needs
bloc = CheckoutBloc(
cartFacade: mockCart,
paymentFacade: mockPayment,
);
});
});
}The facade pattern from Problem 1 makes this possible. If CheckoutBloc depends on AuthFacade instead of AuthBloc, you mock one interface method instead of reconstructing the entire auth state machine.
Problem 5: Navigation and State Lifecycle
At scale, the question "when does this state get created and disposed?" becomes critical. A BLoC created on screen A that's needed on screen B but disposed when screen A pops — this class of bug multiplies with screen count.
The Pattern: State Scope Tiers
Organize state into explicit lifetime tiers:
// Tier 1: App-lifetime state (created once, lives forever)
// Auth, theme, connectivity, feature flags
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => AuthBloc(authRepository)),
BlocProvider(create: (_) => ThemeBloc()),
BlocProvider(create: (_) => ConnectivityBloc()),
],
child: const App(),
)
// Tier 2: Flow-lifetime state (lives for a multi-screen flow)
// Checkout, onboarding, multi-step forms
Navigator(
onGenerateRoute: (settings) => MaterialPageRoute(
builder: (_) => BlocProvider(
create: (_) => CheckoutBloc(),
child: const CheckoutFlow(), // CheckoutBloc lives for the entire flow
),
),
)
// Tier 3: Screen-lifetime state (lives for one screen)
// Search results, form state, local filters
class ProductSearchScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => ProductSearchBloc(productRepository),
child: const ProductSearchView(),
);
}
}With Riverpod, scoping is managed through ProviderScope overrides and autoDispose:
// App-lifetime: no autoDispose
@riverpod
class AuthNotifier extends _$AuthNotifier { /* ... */ }
// Screen-lifetime: autoDispose (default with code gen)
@riverpod
class ProductSearch extends _$ProductSearch { /* auto-disposed when no listeners */ }
// Flow-lifetime: keepAlive with manual invalidation
@riverpod
class CheckoutFlow extends _$CheckoutFlow {
@override
CheckoutState build() {
ref.keepAlive(); // Don't auto-dispose
return CheckoutInitial();
}
}
// Manually invalidate when the flow completes
ref.invalidate(checkoutFlowProvider);Document the tier for every piece of state. When a new developer asks "where does this state live?", the answer should be immediate.
Problem 6: Cross-Feature Communication
At scale, features need to talk to each other. The cart needs to know when a product is favorited. The notification badge needs to know when messages are read. The admin panel needs to react to user actions in real time.
The Pattern: Event Bus (Used Carefully)
A shared event bus lets features communicate without direct dependencies:
// Shared domain events — not BLoC events, application events
abstract class AppEvent {}
class ProductFavorited extends AppEvent {
final String productId;
ProductFavorited(this.productId);
}
class OrderCompleted extends AppEvent {
final String orderId;
OrderCompleted(this.orderId);
}
// Simple event bus
class EventBus {
final _controller = StreamController<AppEvent>.broadcast();
Stream<T> on<T extends AppEvent>() =>
_controller.stream.where((event) => event is T).cast<T>();
void fire(AppEvent event) => _controller.add(event);
void dispose() => _controller.close();
}
// In a BLoC
class CartBloc extends Bloc<CartEvent, CartState> {
late final StreamSubscription _favoriteSub;
CartBloc({required EventBus eventBus}) : super(CartInitial()) {
// Listen for favorites changes
_favoriteSub = eventBus.on<ProductFavorited>().listen((event) {
add(CartItemFavoriteToggled(productId: event.productId));
});
on<CartItemFavoriteToggled>(_onFavoriteToggled);
}
@override
Future<void> close() {
_favoriteSub.cancel();
return super.close();
}
}The warning: Event buses are powerful and dangerous. Without discipline, they become invisible spaghetti — features communicating through events that nobody can trace. Rules:
- App events should be documented and typed (not string-based)
- Every event should have one emitter (single source of truth)
- Every listener should be documented with why it listens
- Never use the event bus for state that should be shared directly (use facades instead)
The Architecture at Scale
Putting it all together, a Flutter app at 50+ screens should have:
app/
├── core/
│ ├── event_bus.dart # Cross-feature communication
│ └── bloc_observer.dart # Centralized audit logging
├── features/
│ ├── auth/
│ │ ├── bloc/ # Internal BLoC
│ │ ├── facade/ # Public interface (AuthFacade)
│ │ └── ui/ # Screens and widgets
│ ├── cart/
│ │ ├── bloc/
│ │ ├── facade/
│ │ └── ui/
│ └── checkout/
│ ├── bloc/ # Uses AuthFacade + CartFacade
│ ├── facade/
│ └── ui/
└── main.dart # Tier 1 state initializationEach feature:
- Owns its state internally
- Exposes a facade for other features
- Communicates through facades (direct dependency) or event bus (loose coupling)
- Has explicit state lifetime (app, flow, or screen)
- Is testable with only its facade dependencies
What Doesn't Change
Some things stay the same regardless of scale:
- Pick the right tool per feature. BLoC for state machines, Riverpod for data, ValueNotifier for local state. Scale doesn't change this — it makes it more important.
- Name things well. At fifty screens, clear naming is the difference between "I can find what I need" and "I need to ask someone."
- Keep state close to where it's used. Global state should be rare. Most state is local or feature-scoped.
- Test the business logic, not the framework. BLoC's pure-Dart testability scales linearly. Integration tests don't.
The Honest Reality
No architecture survives contact with scale unchanged. The patterns in this post aren't rules to follow from day one — they're refactoring targets to move toward as complexity grows.
Start simple. Notice the symptoms. Apply the patterns where they solve real problems. The worst thing you can do is over-architect a ten-screen app with facades, event buses, and three state lifetime tiers. The second worst thing is to pretend a fifty-screen app can survive without them.
Scale is a forcing function for architectural honesty. The patterns that were "good enough" stop being good enough. The conventions that were "understood" stop being understood when new people join. The tests that were "comprehensive" stop covering what matters.
The developers who navigate this well aren't the ones who picked the right tool on day one. They're the ones who recognized when the current approach stopped working and had the judgment to evolve it.
This is part of the Flutter State Management series. For the foundational concepts, start from the beginning. For the decision framework, see [The State Management Decision Matrix](/blog/state-management-decision-matrix).
Related reading:
- BLoC Patterns for Auditable Business Logic
- DDD Bounded Contexts — the backend equivalent of state boundaries
- DDD Aggregates