| name | bloc-state-management |
| description | Expert Bloc/Cubit state management implementation for Flutter apps. Use when implementing state management with flutter_bloc, bloc library, Cubit pattern, or BLoC pattern. Covers events, states, business logic separation, reactive programming with streams, testing, and modern Bloc 9.x+ patterns with latest features. |
| allowed-tools | Read, Edit, Write, Grep, Glob, Bash |
Bloc State Management Skill
Expert assistance for implementing state management using the Bloc/Cubit pattern in Flutter applications.
When to Use This Skill
- Setting up Bloc/Cubit in a Flutter project
- Creating Blocs and Cubits for state management
- Implementing events and states
- Managing complex business logic with Bloc pattern
- Handling async operations and data fetching
- Implementing form validation with Bloc
- Testing Blocs and Cubits
- Using Bloc observers and transformers
- Optimizing Bloc performance
- Migrating between Bloc versions
Setup
Dependencies
dependencies:
flutter_bloc: ^9.0.0
equatable: ^2.0.5 # For value equality
dev_dependencies:
bloc_test: ^10.0.0
mocktail: ^1.0.0
Project Setup
# Add dependencies
flutter pub add flutter_bloc equatable
flutter pub add --dev bloc_test mocktail
App Configuration
import 'package:flutter_bloc/flutter_bloc.dart';
void main() {
// Optional: Global Bloc observer
Bloc.observer = AppBlocObserver();
runApp(const MyApp());
}
// Bloc Observer for debugging
class AppBlocObserver extends BlocObserver {
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
print('${bloc.runtimeType} $change');
}
@override
void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
print('${bloc.runtimeType} $error $stackTrace');
super.onError(bloc, error, stackTrace);
}
@override
void onTransition(Bloc bloc, Transition transition) {
super.onTransition(bloc, transition);
print('${bloc.runtimeType} $transition');
}
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
print('${bloc.runtimeType} $event');
}
}
Core Concepts
Cubit vs Bloc
| Feature | Cubit | Bloc |
|---|---|---|
| Complexity | Simple | Complex |
| Input | Methods | Events |
| Best for | Simple state | Complex business logic |
| Testability | Easy | Very structured |
| Boilerplate | Less | More |
| Traceability | Good | Excellent |
Rule of thumb: Use Cubit for simple state, Bloc for complex logic with clear events.
Cubit Pattern (Simple State Management)
Basic Cubit
import 'package:flutter_bloc/flutter_bloc.dart';
// Simple state (can be primitive or class)
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}
// In widget
BlocProvider(
create: (context) => CounterCubit(),
child: CounterView(),
)
Cubit with Complex State
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
// State class
class TodoState extends Equatable {
const TodoState({
this.todos = const [],
this.isLoading = false,
this.error,
});
final List<Todo> todos;
final bool isLoading;
final String? error;
TodoState copyWith({
List<Todo>? todos,
bool? isLoading,
String? error,
}) {
return TodoState(
todos: todos ?? this.todos,
isLoading: isLoading ?? this.isLoading,
error: error ?? this.error,
);
}
@override
List<Object?> get props => [todos, isLoading, error];
}
// Cubit
class TodoCubit extends Cubit<TodoState> {
TodoCubit(this._repository) : super(const TodoState());
final TodoRepository _repository;
Future<void> loadTodos() async {
emit(state.copyWith(isLoading: true, error: null));
try {
final todos = await _repository.fetchTodos();
emit(state.copyWith(todos: todos, isLoading: false));
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: e.toString(),
));
}
}
Future<void> addTodo(String title) async {
try {
final newTodo = await _repository.addTodo(title);
emit(state.copyWith(
todos: [...state.todos, newTodo],
));
} catch (e) {
emit(state.copyWith(error: e.toString()));
}
}
void removeTodo(String id) {
emit(state.copyWith(
todos: state.todos.where((todo) => todo.id != id).toList(),
));
}
@override
Future<void> close() {
// Cleanup if needed
return super.close();
}
}
Bloc Pattern (Event-Driven State Management)
Complete Bloc Example
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:equatable/equatable.dart';
// Events
abstract class TodoEvent extends Equatable {
const TodoEvent();
@override
List<Object?> get props => [];
}
class TodoLoadRequested extends TodoEvent {
const TodoLoadRequested();
}
class TodoAdded extends TodoEvent {
const TodoAdded(this.title);
final String title;
@override
List<Object?> get props => [title];
}
class TodoDeleted extends TodoEvent {
const TodoDeleted(this.id);
final String id;
@override
List<Object?> get props => [id];
}
class TodoToggled extends TodoEvent {
const TodoToggled(this.id);
final String id;
@override
List<Object?> get props => [id];
}
// States
abstract class TodoState extends Equatable {
const TodoState();
@override
List<Object?> get props => [];
}
class TodoInitial extends TodoState {
const TodoInitial();
}
class TodoLoadInProgress extends TodoState {
const TodoLoadInProgress();
}
class TodoLoadSuccess extends TodoState {
const TodoLoadSuccess(this.todos);
final List<Todo> todos;
@override
List<Object?> get props => [todos];
}
class TodoLoadFailure extends TodoState {
const TodoLoadFailure(this.error);
final String error;
@override
List<Object?> get props => [error];
}
// Bloc
class TodoBloc extends Bloc<TodoEvent, TodoState> {
TodoBloc(this._repository) : super(const TodoInitial()) {
on<TodoLoadRequested>(_onLoadRequested);
on<TodoAdded>(_onAdded);
on<TodoDeleted>(_onDeleted);
on<TodoToggled>(_onToggled);
}
final TodoRepository _repository;
Future<void> _onLoadRequested(
TodoLoadRequested event,
Emitter<TodoState> emit,
) async {
emit(const TodoLoadInProgress());
try {
final todos = await _repository.fetchTodos();
emit(TodoLoadSuccess(todos));
} catch (e) {
emit(TodoLoadFailure(e.toString()));
}
}
Future<void> _onAdded(
TodoAdded event,
Emitter<TodoState> emit,
) async {
if (state is TodoLoadSuccess) {
try {
final newTodo = await _repository.addTodo(event.title);
final currentState = state as TodoLoadSuccess;
emit(TodoLoadSuccess([...currentState.todos, newTodo]));
} catch (e) {
emit(TodoLoadFailure(e.toString()));
}
}
}
void _onDeleted(
TodoDeleted event,
Emitter<TodoState> emit,
) {
if (state is TodoLoadSuccess) {
final currentState = state as TodoLoadSuccess;
emit(TodoLoadSuccess(
currentState.todos.where((todo) => todo.id != event.id).toList(),
));
}
}
void _onToggled(
TodoToggled event,
Emitter<TodoState> emit,
) {
if (state is TodoLoadSuccess) {
final currentState = state as TodoLoadSuccess;
emit(TodoLoadSuccess(
currentState.todos.map((todo) {
return todo.id == event.id
? todo.copyWith(completed: !todo.completed)
: todo;
}).toList(),
));
}
}
@override
Future<void> close() {
// Cleanup
return super.close();
}
}
Widget Integration
BlocProvider (Single Bloc)
BlocProvider(
create: (context) => TodoCubit(
context.read<TodoRepository>(),
)..loadTodos(), // Can call methods immediately
child: const TodoView(),
)
MultiBlocProvider (Multiple Blocs)
MultiBlocProvider(
providers: [
BlocProvider(create: (context) => AuthBloc()),
BlocProvider(create: (context) => TodoBloc()),
BlocProvider(create: (context) => SettingsBloc()),
],
child: const MyApp(),
)
BlocBuilder (Rebuild on State Change)
BlocBuilder<TodoCubit, TodoState>(
builder: (context, state) {
if (state.isLoading) {
return const CircularProgressIndicator();
}
if (state.error != null) {
return Text('Error: ${state.error}');
}
return ListView.builder(
itemCount: state.todos.length,
itemBuilder: (context, index) {
return TodoItem(todo: state.todos[index]);
},
);
},
)
// With buildWhen for optimization
BlocBuilder<CounterCubit, int>(
buildWhen: (previous, current) => previous != current,
builder: (context, state) => Text('$state'),
)
BlocSelector (Rebuild on Specific Property)
// Only rebuilds when name changes, not entire user state
BlocSelector<UserCubit, UserState, String>(
selector: (state) => state.name,
builder: (context, name) => Text(name),
)
BlocListener (Side Effects)
BlocListener<TodoBloc, TodoState>(
listener: (context, state) {
if (state is TodoLoadFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error)),
);
}
},
child: const TodoView(),
)
// Listen to specific events
BlocListener<TodoBloc, TodoState>(
listenWhen: (previous, current) {
return previous is TodoLoadInProgress &&
current is TodoLoadSuccess;
},
listener: (context, state) {
// Show success message
},
child: const TodoView(),
)
BlocConsumer (Builder + Listener)
BlocConsumer<TodoBloc, TodoState>(
listener: (context, state) {
if (state is TodoLoadFailure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.error)),
);
}
},
builder: (context, state) {
if (state is TodoLoadInProgress) {
return const CircularProgressIndicator();
}
if (state is TodoLoadSuccess) {
return TodoList(todos: state.todos);
}
return const SizedBox();
},
)
Reading/Dispatching Events
// Read Cubit/Bloc instance (doesn't rebuild)
context.read<TodoCubit>().addTodo('New Todo');
// Watch state (rebuilds on change)
final state = context.watch<TodoCubit>().state;
// Select specific property
final count = context.select(
(TodoCubit cubit) => cubit.state.todos.length,
);
// For Bloc: dispatch events
context.read<TodoBloc>().add(const TodoAdded('New Todo'));
Advanced Patterns
Bloc Transformers (Event Processing)
import 'package:bloc_concurrency/bloc_concurrency.dart';
import 'package:stream_transform/stream_transform.dart';
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc() : super(const SearchState()) {
// Debounce search events
on<SearchQueryChanged>(
_onQueryChanged,
transformer: debounce(const Duration(milliseconds: 300)),
);
// Sequential processing
on<DataLoadRequested>(
_onDataLoadRequested,
transformer: sequential(),
);
// Concurrent processing
on<DataRefreshRequested>(
_onDataRefreshRequested,
transformer: concurrent(),
);
// Drop previous events
on<ButtonPressed>(
_onButtonPressed,
transformer: droppable(),
);
// Restart on new event
on<InputChanged>(
_onInputChanged,
transformer: restartable(),
);
}
}
// Custom debounce transformer
EventTransformer<E> debounce<E>(Duration duration) {
return (events, mapper) => events.debounce(duration).switchMap(mapper);
}
Bloc Communication
// Bloc listening to another Bloc
class DependentBloc extends Bloc<DependentEvent, DependentState> {
DependentBloc(this._authBloc) : super(DependentInitial()) {
_authSubscription = _authBloc.stream.listen((authState) {
if (authState is Authenticated) {
add(const DataLoadRequested());
} else if (authState is Unauthenticated) {
add(const DataCleared());
}
});
on<DataLoadRequested>(_onDataLoadRequested);
on<DataCleared>(_onDataCleared);
}
final AuthBloc _authBloc;
late StreamSubscription _authSubscription;
@override
Future<void> close() {
_authSubscription.cancel();
return super.close();
}
}
Hydrated Bloc (Persistence)
// Add dependency: hydrated_bloc: ^9.1.2
import 'package:hydrated_bloc/hydrated_bloc.dart';
import 'package:path_provider/path_provider.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
HydratedBloc.storage = await HydratedStorage.build(
storageDirectory: await getApplicationDocumentsDirectory(),
);
runApp(const MyApp());
}
// Hydrated Cubit
class CounterCubit extends HydratedCubit<int> {
CounterCubit() : super(0);
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
@override
int? fromJson(Map<String, dynamic> json) => json['value'] as int?;
@override
Map<String, dynamic>? toJson(int state) => {'value': state};
}
Testing
Cubit Testing
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockTodoRepository extends Mock implements TodoRepository {}
void main() {
group('TodoCubit', () {
late TodoRepository repository;
setUp(() {
repository = MockTodoRepository();
});
test('initial state is TodoState()', () {
expect(
TodoCubit(repository).state,
equals(const TodoState()),
);
});
blocTest<TodoCubit, TodoState>(
'emits loading and success when loadTodos succeeds',
build: () {
when(() => repository.fetchTodos())
.thenAnswer((_) async => [Todo(id: '1', title: 'Test')]);
return TodoCubit(repository);
},
act: (cubit) => cubit.loadTodos(),
expect: () => [
const TodoState(isLoading: true),
TodoState(
isLoading: false,
todos: [Todo(id: '1', title: 'Test')],
),
],
);
blocTest<TodoCubit, TodoState>(
'emits loading and failure when loadTodos fails',
build: () {
when(() => repository.fetchTodos())
.thenThrow(Exception('Failed'));
return TodoCubit(repository);
},
act: (cubit) => cubit.loadTodos(),
expect: () => [
const TodoState(isLoading: true),
const TodoState(
isLoading: false,
error: 'Exception: Failed',
),
],
);
});
}
Bloc Testing
void main() {
group('TodoBloc', () {
late TodoRepository repository;
setUp(() {
repository = MockTodoRepository();
});
test('initial state is TodoInitial', () {
expect(TodoBloc(repository).state, equals(const TodoInitial()));
});
blocTest<TodoBloc, TodoState>(
'emits [TodoLoadInProgress, TodoLoadSuccess] when load succeeds',
build: () {
when(() => repository.fetchTodos())
.thenAnswer((_) async => [Todo(id: '1', title: 'Test')]);
return TodoBloc(repository);
},
act: (bloc) => bloc.add(const TodoLoadRequested()),
expect: () => [
const TodoLoadInProgress(),
TodoLoadSuccess([Todo(id: '1', title: 'Test')]),
],
);
blocTest<TodoBloc, TodoState>(
'emits [TodoLoadInProgress, TodoLoadFailure] when load fails',
build: () {
when(() => repository.fetchTodos())
.thenThrow(Exception('Failed'));
return TodoBloc(repository);
},
act: (bloc) => bloc.add(const TodoLoadRequested()),
expect: () => [
const TodoLoadInProgress(),
const TodoLoadFailure('Exception: Failed'),
],
);
blocTest<TodoBloc, TodoState>(
'emits updated list when todo added',
build: () {
when(() => repository.fetchTodos())
.thenAnswer((_) async => []);
when(() => repository.addTodo(any()))
.thenAnswer((_) async => Todo(id: '1', title: 'New'));
return TodoBloc(repository)..add(const TodoLoadRequested());
},
seed: () => const TodoLoadSuccess([]),
act: (bloc) => bloc.add(const TodoAdded('New')),
expect: () => [
TodoLoadSuccess([Todo(id: '1', title: 'New')]),
],
);
});
}
Widget Testing with Bloc
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:mocktail/mocktail.dart';
class MockTodoBloc extends MockBloc<TodoEvent, TodoState>
implements TodoBloc {}
void main() {
late TodoBloc todoBloc;
setUp(() {
todoBloc = MockTodoBloc();
});
testWidgets('renders loading indicator when loading', (tester) async {
when(() => todoBloc.state).thenReturn(const TodoLoadInProgress());
await tester.pumpWidget(
MaterialApp(
home: BlocProvider.value(
value: todoBloc,
child: const TodoView(),
),
),
);
expect(find.byType(CircularProgressIndicator), findsOneWidget);
});
testWidgets('renders todos when loaded', (tester) async {
when(() => todoBloc.state).thenReturn(
TodoLoadSuccess([
Todo(id: '1', title: 'Test Todo'),
]),
);
await tester.pumpWidget(
MaterialApp(
home: BlocProvider.value(
value: todoBloc,
child: const TodoView(),
),
),
);
expect(find.text('Test Todo'), findsOneWidget);
});
}
Project Structure
lib/
├── core/
│ ├── blocs/ # Shared blocs (auth, theme, etc.)
│ └── utils/
├── features/
│ └── todos/
│ ├── data/
│ │ ├── models/
│ │ └── repositories/
│ ├── domain/
│ │ └── entities/
│ └── presentation/
│ ├── blocs/ # Feature-specific blocs/cubits
│ │ └── todo/
│ │ ├── todo_bloc.dart
│ │ ├── todo_event.dart
│ │ └── todo_state.dart
│ ├── pages/
│ └── widgets/
└── main.dart
Common Patterns
Form Validation Bloc
class LoginFormCubit extends Cubit<LoginFormState> {
LoginFormCubit() : super(const LoginFormState());
void emailChanged(String email) {
final emailError = _validateEmail(email);
emit(state.copyWith(
email: email,
emailError: emailError,
));
}
void passwordChanged(String password) {
final passwordError = _validatePassword(password);
emit(state.copyWith(
password: password,
passwordError: passwordError,
));
}
Future<void> submit() async {
if (!state.isValid) return;
emit(state.copyWith(status: FormStatus.submitting));
try {
await _authRepository.login(state.email, state.password);
emit(state.copyWith(status: FormStatus.success));
} catch (e) {
emit(state.copyWith(
status: FormStatus.failure,
errorMessage: e.toString(),
));
}
}
String? _validateEmail(String email) {
if (email.isEmpty) return 'Email is required';
if (!email.contains('@')) return 'Invalid email';
return null;
}
String? _validatePassword(String password) {
if (password.isEmpty) return 'Password is required';
if (password.length < 6) return 'Password too short';
return null;
}
}
Pagination Bloc
class PostListBloc extends Bloc<PostListEvent, PostListState> {
PostListBloc(this._repository) : super(const PostListState()) {
on<PostListLoadRequested>(_onLoadRequested);
on<PostListLoadMore>(_onLoadMore);
}
final PostRepository _repository;
static const _pageSize = 20;
Future<void> _onLoadRequested(
PostListLoadRequested event,
Emitter<PostListState> emit,
) async {
emit(state.copyWith(status: PostListStatus.loading));
try {
final posts = await _repository.fetchPosts(0, _pageSize);
emit(state.copyWith(
status: PostListStatus.success,
posts: posts,
hasMore: posts.length >= _pageSize,
));
} catch (e) {
emit(state.copyWith(
status: PostListStatus.failure,
error: e.toString(),
));
}
}
Future<void> _onLoadMore(
PostListLoadMore event,
Emitter<PostListState> emit,
) async {
if (!state.hasMore || state.isLoadingMore) return;
emit(state.copyWith(isLoadingMore: true));
try {
final posts = await _repository.fetchPosts(
state.posts.length,
_pageSize,
);
emit(state.copyWith(
posts: [...state.posts, ...posts],
hasMore: posts.length >= _pageSize,
isLoadingMore: false,
));
} catch (e) {
emit(state.copyWith(
isLoadingMore: false,
error: e.toString(),
));
}
}
}
Best Practices
- Use Cubit for simple state, Bloc for complex event-driven logic
- Use Equatable for automatic value comparison
- Separate events, states, and bloc into different files for clarity
- Use sealed classes or freezed for exhaustive state matching
- Name events in past tense (UserLoggedIn, DataLoadRequested)
- Name states descriptively (Loading, Success, Failure)
- Use BlocObserver for debugging and logging
- Test blocs thoroughly with bloc_test
- Use event transformers to control event processing
- Avoid accessing BuildContext in Blocs (pass data through events)
- Close blocs properly to prevent memory leaks
- Use BlocProvider.value only for existing instances
- Use BlocSelector to optimize rebuilds
- Keep business logic in Blocs, not in widgets
- Use repositories for data access, not directly in Blocs
Common Issues
Issue: BlocProvider.of() called with no matching provider
Solution: Wrap widget with BlocProvider
BlocProvider(
create: (context) => TodoBloc(),
child: const TodoView(),
)
Issue: Bloc is not updating UI
Solution: Ensure states are properly compared (use Equatable)
class TodoState extends Equatable {
@override
List<Object?> get props => [todos, isLoading];
}
Issue: Memory leak
Solution: Close blocs and cancel subscriptions
@override
Future<void> close() {
_subscription.cancel();
return super.close();
}
Issue: Cannot emit new states after close
Solution: Check if closed before emitting
if (!isClosed) {
emit(newState);
}
Resources
- Official Docs: https://bloclibrary.dev
- Examples: https://github.com/felangel/bloc/tree/master/examples
- Bloc Extension: VS Code and IntelliJ extensions for code generation
- Bloc Architecture: https://bloclibrary.dev/#/architecture
Notes
This skill focuses on Bloc/Cubit patterns. For Flutter-specific UI patterns, use the flutter-developer skill. For alternative state management, see the riverpod-state-management skill. Consider using code generation tools like freezed for immutable state classes.