🧱 Flutter Bloc Cheat Sheet
BLoC pattern with clean architecture — events, states, Cubit, builders.
🟢 Cubit (Simple State)
Basic Cubit
import 'package:flutter_bloc/flutter_bloc.dart';
// State
class CounterState {
final int count;
const CounterState(this.count);
}
// Cubit
class CounterCubit extends Cubit<CounterState> {
CounterCubit() : super(const CounterState(0));
void increment() => emit(CounterState(state.count + 1));
void decrement() => emit(CounterState(state.count - 1));
void reset() => emit(const CounterState(0));
}Async Cubit with sealed state
// States (sealed class — Dart 3)
sealed class UserState {}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
final User user;
UserLoaded(this.user);
}
class UserError extends UserState {
final String message;
UserError(this.message);
}
// Cubit
class UserCubit extends Cubit<UserState> {
final UserRepository _repo;
UserCubit(this._repo) : super(UserInitial());
Future<void> loadUser(String id) async {
emit(UserLoading());
try {
final user = await _repo.getUser(id);
emit(UserLoaded(user));
} catch (e) {
emit(UserError(e.toString()));
}
}
}🔵 BLoC (Events → States)
Events
// user_event.dart
sealed class UserEvent {}
class UserLoadRequested extends UserEvent {
final String id;
UserLoadRequested(this.id);
}
class UserRefreshRequested extends UserEvent {}
class UserLogoutRequested extends UserEvent {}BLoC implementation
// user_bloc.dart
class UserBloc extends Bloc<UserEvent, UserState> {
final GetUserUseCase _getUser;
UserBloc({required GetUserUseCase getUserUseCase})
: _getUser = getUserUseCase,
super(UserInitial()) {
on<UserLoadRequested>(_onLoadRequested);
on<UserRefreshRequested>(_onRefreshRequested);
on<UserLogoutRequested>(_onLogout);
}
Future<void> _onLoadRequested(
UserLoadRequested event,
Emitter<UserState> emit,
) async {
emit(UserLoading());
final result = await _getUser(event.id);
result.fold(
(failure) => emit(UserError(failure.message)),
(user) => emit(UserLoaded(user)),
);
}
Future<void> _onRefreshRequested(
UserRefreshRequested event,
Emitter<UserState> emit,
) async {
if (state is UserLoaded) {
final current = (state as UserLoaded).user;
// refresh with current user id
add(UserLoadRequested(current.id));
}
}
void _onLogout(UserLogoutRequested event, Emitter<UserState> emit) {
emit(UserInitial());
}
}Transformers (debounce, throttle)
// Add bloc_concurrency package
import 'package:bloc_concurrency/bloc_concurrency.dart';
// Only one at a time — drop if already running
on<SearchEvent>(_onSearch, transformer: droppable());
// Last one wins — cancel previous
on<SearchEvent>(_onSearch, transformer: restartable());
// Queue all events in order
on<SearchEvent>(_onSearch, transformer: sequential());
// Debounce with RxDart
import 'package:rxdart/rxdart.dart';
on<SearchChanged>(
_onSearchChanged,
transformer: (events, mapper) => events
.debounceTime(const Duration(milliseconds: 300))
.switchMap(mapper),
);📦 State Best Practices
Equatable state (avoid rebuilds)
// Add equatable package
class UserLoaded extends UserState with EquatableMixin {
final User user;
const UserLoaded(this.user);
@override
List<Object> get props => [user]; // only rebuild when user changes
}
// Or freeze package (code generation)
// @freezed
// class UserState with _$UserState { ... }CopyWith pattern for complex state
class HomeState {
final List<Post> posts;
final bool isLoading;
final String? error;
final int page;
const HomeState({
this.posts = const [],
this.isLoading = false,
this.error,
this.page = 1,
});
HomeState copyWith({
List<Post>? posts,
bool? isLoading,
String? error,
int? page,
}) => HomeState(
posts: posts ?? this.posts,
isLoading: isLoading ?? this.isLoading,
error: error, // allow null to clear error
page: page ?? this.page,
);
}
// In Cubit
emit(state.copyWith(isLoading: true, error: null));
emit(state.copyWith(posts: newPosts, isLoading: false));🏗️ Providing Blocs
BlocProvider
// Provide to widget tree
BlocProvider(
create: (context) => sl<UserBloc>()..add(UserLoadRequested('1')),
child: const UserScreen(),
)
// Provide existing instance
BlocProvider.value(
value: existingBloc,
child: const SomeWidget(),
)
// Multiple blocs
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => sl<UserBloc>()),
BlocProvider(create: (_) => sl<PostBloc>()),
BlocProvider(create: (_) => sl<ThemeCubit>()),
],
child: const App(),
)Access bloc without providing
// Read (no rebuild)
context.read<UserBloc>().add(UserRefreshRequested());
// Watch (rebuilds on state change)
final state = context.watch<UserBloc>().state;
// Select (rebuilds only on specific value change)
final name = context.select<UserBloc, String>(
(bloc) => bloc.state is UserLoaded
? (bloc.state as UserLoaded).user.name
: '',
);🔨 BlocBuilder / Listener / Consumer
BlocBuilder
BlocBuilder<UserBloc, UserState>(
// Optional: only rebuild for specific states
buildWhen: (prev, curr) => curr is! UserLoading,
builder: (context, state) => switch (state) {
UserInitial() => const SizedBox(),
UserLoading() => const CircularProgressIndicator(),
UserLoaded(:final user) => UserCard(user: user),
UserError(:final message) => ErrorView(message: message),
},
)BlocListener (side effects)
BlocListener<UserBloc, UserState>(
listenWhen: (prev, curr) => curr is UserError,
listener: (context, state) {
if (state is UserError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
if (state is UserLoaded && state.user.needsOnboarding) {
context.go('/onboarding');
}
},
child: const UserContent(),
)BlocConsumer (build + listen)
BlocConsumer<UserBloc, UserState>(
listenWhen: (prev, curr) => curr is UserError || curr is UserLoaded,
listener: (context, state) {
if (state is UserError) {
_showErrorDialog(context, state.message);
}
},
buildWhen: (prev, curr) => curr is! UserError,
builder: (context, state) {
if (state is UserLoading) return const LoadingWidget();
if (state is UserLoaded) return UserCard(user: state.user);
return const SizedBox();
},
)
// Multiple listeners
MultiBlocListener(
listeners: [
BlocListener<UserBloc, UserState>(listener: ...),
BlocListener<PostBloc, PostState>(listener: ...),
],
child: const Content(),
)👁️ BlocObserver
Global BLoC observer for debugging
class AppBlocObserver extends BlocObserver {
@override
void onCreate(BlocBase bloc) {
super.onCreate(bloc);
debugPrint('onCreate: ${bloc.runtimeType}');
}
@override
void onEvent(Bloc bloc, Object? event) {
super.onEvent(bloc, event);
debugPrint('onEvent: ${bloc.runtimeType} — $event');
}
@override
void onChange(BlocBase bloc, Change change) {
super.onChange(bloc, change);
debugPrint('onChange: ${bloc.runtimeType}\n'
' current: ${change.currentState}\n'
' next: ${change.nextState}');
}
@override
void onError(BlocBase bloc, Object error, StackTrace st) {
debugPrint('onError: ${bloc.runtimeType} — $error');
super.onError(bloc, error, st);
}
}
// Register in main
void main() {
Bloc.observer = AppBlocObserver();
runApp(const MyApp());
}🏛️ Clean Architecture Structure
Folder structure
lib/ ├── core/ │ ├── error/ # failures, exceptions │ ├── usecase/ # UseCase base class │ ├── utils/ │ └── di/ # service locator ├── features/ │ └── user/ │ ├── data/ │ │ ├── datasources/ │ │ │ ├── user_remote_ds.dart │ │ │ └── user_local_ds.dart │ │ ├── models/ │ │ │ └── user_model.dart │ │ └── repositories/ │ │ └── user_repo_impl.dart │ ├── domain/ │ │ ├── entities/ │ │ │ └── user.dart │ │ ├── repositories/ │ │ │ └── user_repository.dart # abstract │ │ └── usecases/ │ │ └── get_user.dart │ └── presentation/ │ ├── bloc/ │ │ ├── user_bloc.dart │ │ ├── user_event.dart │ │ └── user_state.dart │ ├── pages/ │ │ └── user_screen.dart │ └── widgets/ │ └── user_card.dart └── main.dart
UseCase base class
// core/usecase/usecase.dart
import 'package:fpdart/fpdart.dart';
import '../error/failures.dart';
abstract class UseCase<Type, Params> {
Future<Either<Failure, Type>> call(Params params);
}
class NoParams {}
// Example use case
class GetUserUseCase implements UseCase<User, String> {
final UserRepository _repo;
GetUserUseCase(this._repo);
@override
Future<Either<Failure, User>> call(String id) => _repo.getUser(id);
}⚡ Tips & Patterns
Emit safely (check if closed)
// In a Cubit/Bloc
Future<void> loadData() async {
if (isClosed) return; // guard before async
emit(Loading());
final result = await _repo.fetch();
if (isClosed) return; // guard after async
emit(Loaded(result));
}Stream subscription in Bloc
class NotificationBloc extends Bloc<NotificationEvent, NotificationState> {
late final StreamSubscription _sub;
NotificationBloc(NotificationService svc) : super(NotificationInitial()) {
_sub = svc.stream.listen((n) => add(NotificationReceived(n)));
on<NotificationReceived>(_onReceived);
}
@override
Future<void> close() {
_sub.cancel();
return super.close();
}
}Testing a Cubit / Bloc
// Add bloc_test package
import 'package:bloc_test/bloc_test.dart';
blocTest<CounterCubit, CounterState>(
'emits [CounterState(1)] when increment is called',
build: () => CounterCubit(),
act: (cubit) => cubit.increment(),
expect: () => [const CounterState(1)],
);
// Bloc test
blocTest<UserBloc, UserState>(
'emits [loading, loaded] when UserLoadRequested added',
build: () {
when(() => mockRepo.getUser('1')).thenAnswer((_) async => fakeUser);
return UserBloc(getUserUseCase: GetUserUseCase(mockRepo));
},
act: (bloc) => bloc.add(UserLoadRequested('1')),
expect: () => [UserLoading(), UserLoaded(fakeUser)],
);