State Management
The Supa Architecture framework uses the BLoC (Business Logic Component) pattern extensively for state management, following a unidirectional data flow that makes applications easier to reason about and test.
BLoC Pattern Implementation
The framework separates presentation from business logic using a structured approach that promotes maintainability and testability.
Core Characteristics
The BLoC pattern separates presentation from business logic, with:
- Events: Triggered by user actions or external systems
- BLoC: Processes events and updates state accordingly
- States: Represent snapshots of data that the UI displays
- Repositories: Interface with data sources to retrieve or manipulate data
Architecture Flow
UI Components → Events → BLoC → Repositories → Data Sources
← ← ← ←
States BLoC Repositories Data Sources
The data flows unidirectionally through the following steps:
- User Interaction: User performs an action in the UI
- Event Dispatch: UI dispatches an event to the corresponding BLoC
- Event Processing: BLoC processes the event and determines required actions
- Repository Call: BLoC calls repository methods to fetch or modify data
- Data Access: Repository communicates with APIs or local storage
- Response Processing: Data flows back through the layers
- State Emission: BLoC emits new state based on the results
- UI Update: UI rebuilds automatically based on the new state
Key Benefits
This pattern ensures:
- Separation of Concerns: Clear boundaries between UI, business logic, and data
- Testability: Each layer can be tested independently with mock implementations
- Maintainability: Changes in one layer have minimal impact on others
- Predictability: Data flows in one direction, making behavior predictable
- Debuggability: State changes can be easily tracked and logged
State Management Dependencies
The framework uses several key libraries for state management:
| Package | Purpose |
|---|---|
bloc |
Core state management library providing BLoC functionality |
flutter_bloc |
Flutter widgets for BLoC pattern integration |
equatable |
Simplifies equality comparisons for immutable objects |
get_it |
Service locator for dependency injection |
injectable |
Code generator for dependency injection setup |
BLoC Structure
Event Definition
Events represent actions that can occur in the application:
abstract class ExampleEvent extends Equatable {
const ExampleEvent();
}
class LoadDataEvent extends ExampleEvent {
@override
List<Object> get props => [];
}
class RefreshDataEvent extends ExampleEvent {
@override
List<Object> get props => [];
}
State Definition
States represent the current condition of the application:
abstract class ExampleState extends Equatable {
const ExampleState();
}
class ExampleInitial extends ExampleState {
@override
List<Object> get props => [];
}
class ExampleLoading extends ExampleState {
@override
List<Object> get props => [];
}
class ExampleLoaded extends ExampleState {
final List<DataModel> data;
const ExampleLoaded({required this.data});
@override
List<Object> get props => [data];
}
class ExampleError extends ExampleState {
final String message;
const ExampleError({required this.message});
@override
List<Object> get props => [message];
}
BLoC Implementation
class ExampleBloc extends Bloc<ExampleEvent, ExampleState> {
final ExampleRepository repository;
ExampleBloc({required this.repository}) : super(ExampleInitial()) {
on<LoadDataEvent>(_onLoadData);
on<RefreshDataEvent>(_onRefreshData);
}
Future<void> _onLoadData(
LoadDataEvent event,
Emitter<ExampleState> emit,
) async {
emit(ExampleLoading());
try {
final data = await repository.fetchData();
emit(ExampleLoaded(data: data));
} catch (error) {
emit(ExampleError(message: error.toString()));
}
}
Future<void> _onRefreshData(
RefreshDataEvent event,
Emitter<ExampleState> emit,
) async {
try {
final data = await repository.refreshData();
emit(ExampleLoaded(data: data));
} catch (error) {
emit(ExampleError(message: error.toString()));
}
}
}
Framework BLoCs
The framework includes several pre-built BLoCs for common functionality:
AuthenticationBloc
Manages user authentication state and processes authentication-related events:
- Events: Login, logout, token refresh, tenant selection
- States: Unauthenticated, authenticating, authenticated, tenant selection
- Responsibilities: Handle login flows, manage authentication tokens, switch tenants
PushNotificationBloc
Handles push notification state and processing:
- Events: Initialize notifications, process notification, handle permission
- States: Permission states, notification processing states
- Responsibilities: Manage notification permissions, process incoming notifications
ErrorHandlingBloc
Manages global error state and error reporting:
- Events: Report error, clear error, handle error
- States: Error states with categorization and details
- Responsibilities: Centralize error handling, integrate with error reporting services
TenantBloc
Manages multi-tenant functionality:
- Events: Switch tenant, load tenant data
- States: Tenant selection, tenant loaded
- Responsibilities: Handle tenant switching, manage tenant-specific configuration
UI Integration
BlocBuilder
Use BlocBuilder to rebuild UI based on state changes:
BlocBuilder<ExampleBloc, ExampleState>(
builder: (context, state) {
if (state is ExampleLoading) {
return LoadingIndicator();
} else if (state is ExampleLoaded) {
return DataListView(data: state.data);
} else if (state is ExampleError) {
return ErrorMessage(message: state.message);
}
return EmptyView();
},
)
BlocListener
Use BlocListener for side effects like navigation or showing dialogs:
BlocListener<ExampleBloc, ExampleState>(
listener: (context, state) {
if (state is ExampleError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: ChildWidget(),
)
BlocConsumer
Combine BlocBuilder and BlocListener for both UI updates and side effects:
BlocConsumer<ExampleBloc, ExampleState>(
listener: (context, state) {
// Handle side effects
},
builder: (context, state) {
// Build UI based on state
},
)
Dependency Injection
BLoCs are registered with the dependency injection container for easy access:
// Registration
GetIt.instance.registerFactory<ExampleBloc>(
() => ExampleBloc(repository: GetIt.instance<ExampleRepository>()),
);
// Usage in widgets
class ExamplePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider<ExampleBloc>(
create: (context) => GetIt.instance<ExampleBloc>(),
child: ExampleView(),
);
}
}
Best Practices
1. State Immutability
Always use immutable state objects with Equatable for proper state comparison:
class ExampleState extends Equatable {
final String data;
final bool isLoading;
const ExampleState({
required this.data,
required this.isLoading,
});
@override
List<Object> get props => [data, isLoading];
ExampleState copyWith({
String? data,
bool? isLoading,
}) {
return ExampleState(
data: data ?? this.data,
isLoading: isLoading ?? this.isLoading,
);
}
}
2. Error Handling
Include proper error handling in BLoC event handlers:
Future<void> _onLoadData(
LoadDataEvent event,
Emitter<ExampleState> emit,
) async {
try {
emit(state.copyWith(isLoading: true));
final data = await repository.fetchData();
emit(state.copyWith(data: data, isLoading: false));
} on NetworkException catch (e) {
emit(ExampleError(message: 'Network error: ${e.message}'));
} on AuthenticationException catch (e) {
emit(ExampleError(message: 'Authentication error: ${e.message}'));
} catch (e) {
emit(ExampleError(message: 'Unexpected error: ${e.toString()}'));
}
}
3. Testing
Write comprehensive tests for BLoCs:
group('ExampleBloc', () {
late ExampleBloc bloc;
late MockExampleRepository mockRepository;
setUp(() {
mockRepository = MockExampleRepository();
bloc = ExampleBloc(repository: mockRepository);
});
blocTest<ExampleBloc, ExampleState>(
'emits [ExampleLoading, ExampleLoaded] when LoadDataEvent succeeds',
build: () {
when(() => mockRepository.fetchData())
.thenAnswer((_) async => [DataModel()]);
return bloc;
},
act: (bloc) => bloc.add(LoadDataEvent()),
expect: () => [
ExampleLoading(),
ExampleLoaded(data: [DataModel()]),
],
);
});