0020 State Management with BLoC Pattern
- Author:
- Catalyst Engineering Team
- Created:
- 2024-01-15
- Status:
- Accepted
- Tags:
-
Context¶
The Catalyst Voices application requires a state management solution that:
- Handles complex asynchronous operations
- Supports reactive UI updates
- Enables testability
- Provides clear separation of business logic from UI
- Supports cross-feature communication
ADR 0005 established the high-level decision to use BLoC pattern. This ADR provides detailed implementation decisions.
Decision¶
We use flutter_bloc package with the following patterns:
- BLoC for Complex State: Use
Blocclass for state machines with multiple events - Cubit for State Management: Use
Cubitclass for state management, not just simpler cases. Cubit is also handy when there is a lot of triggering of internal events of business logic, as methods can be private. In BLoC, events are public classes, and every method needs to have emitter possibilities. - Service Layer for Data Merging: Services sit between BLoCs and repositories to merge information from different sources. For example, local proposals and remote proposals are merged into one stream in ProposalService.
- Observers for Cross-BLoC Communication: Use observers (UserObserver, ActiveCampaignObserver) in the service layer. This avoids direct BLoC-to-BLoC communication, which creates coupling and noisy state emissions.
- Signals for User Event Information: Signals are used primarily for user event information that is not shown in UI elements directly. For example, showing success snackbars, triggering route changes based on tab selection. Signals are handled in pages (e.g., WorkspacePage), not in other BLoCs.
- Repository Pattern: BLoCs interact with services, which interact with repositories. BLoCs never directly access APIs or databases.
Implementation Patterns¶
BLoC Structure¶
class ProposalBloc extends Bloc<ProposalEvent, ProposalState> {
final ProposalService service;
ProposalBloc(this.service) : super(ProposalInitial()) {
on<LoadProposal>(_onLoadProposal);
on<UpdateProposal>(_onUpdateProposal);
}
Future<void> _onLoadProposal(
LoadProposal event,
Emitter<ProposalState> emit,
) async {
emit(ProposalLoading());
try {
final proposal = await service.getProposal(event.id);
emit(ProposalLoaded(proposal));
} catch (e) {
emit(ProposalError(e));
}
}
}
Cubit Structure¶
class SessionCubit extends Cubit<SessionState> {
final AuthService service;
SessionCubit(this.service) : super(SessionInitial());
Future<void> login(String mnemonic) async {
emit(SessionLoading());
try {
final session = await service.authenticate(mnemonic);
emit(SessionAuthenticated(session));
} catch (e) {
emit(SessionError(e));
}
}
// Private methods can trigger internal business logic
void _updateSessionInternal() {
// Internal logic without needing public events
}
}
Service Layer Pattern¶
class ProposalService {
final ProposalRepository repository;
final ActiveCampaignObserver observer;
Stream<List<Proposal>> watchProposals() {
// Merge local and remote proposals into one stream
return Rx.combineLatest(
repository.watchLocalProposals(),
repository.watchRemoteProposals(),
(local, remote) => _mergeProposals(local, remote),
);
}
}
Observer Pattern for Cross-BLoC Communication¶
// Service uses observer to track state changes
class UserService {
final UserObserver userObserver;
Future<void> updateUser(User user) async {
// Update observer, which notifies all interested services
userObserver.user = user;
}
}
// BLoCs listen to observers via services, not directly
class AccountCubit extends Cubit<AccountState> {
final UserService userService;
AccountCubit(this.userService) : super(AccountInitial()) {
userService.watchUser.listen((user) {
// React to user changes
emit(AccountUpdated(user));
});
}
}
Signal Pattern¶
// Emit signal from BLoC
class WorkspaceBloc extends Bloc<WorkspaceEvent, WorkspaceState> {
void _onDeleteDraft(DeleteDraftEvent event, Emitter emit) {
// ... delete logic ...
emitSignal(DeletedDraftWorkspaceSignal());
}
}
// Handle signal in page, not in another BLoC
class WorkspacePage extends StatefulWidget {
@override
void handleSignal(WorkspaceSignal signal) {
switch (signal) {
case DeletedDraftWorkspaceSignal():
_showDeleteSuccessSnackBar();
// ... other signals ...
}
}
}
Alternatives Considered¶
Provider¶
- Pros: Simple, built into Flutter
- Cons: Less structured, harder to test complex flows
- Rejected: Not suitable for complex state machines
Riverpod¶
- Pros: Compile-time safety, excellent testing
- Cons: Different paradigm, migration cost
- Rejected: BLoC already established, migration not justified
Redux¶
- Pros: Predictable state management
- Cons: Too much boilerplate, not idiomatic for Flutter
- Rejected: BLoC provides better Flutter integration
Consequences¶
Positive¶
- Clear separation of business logic and UI
- Excellent testability with bloc_test package
- Reactive updates via Streams
- Predictable state transitions
- Good tooling support (BlocObserver for debugging)
- Service layer enables data merging from multiple sources
- Observer pattern reduces coupling between BLoCs
- Signals provide clean user event information mechanism
Negative¶
- Learning curve for developers new to reactive programming
- Some boilerplate for simple state
- Requires understanding of Streams and async patterns
- Service layer adds an additional abstraction layer
Best Practices Established¶
- One BLoC per feature domain
- Service layer for data merging and business logic
- Repository pattern for data access
- ViewModels for UI-specific transformations
- Observer pattern for cross-BLoC communication via services
- Signal pattern for user event information (handled in pages)
- Error handling via error states
- Cubit for internal business logic with private methods