By Abdelrahman Saed — Senior Mobile Engineer. Last updated: 2026-08-10.
BLoC (Business Logic Component) is the state management pattern we use across iStoria's 50+ modules. It separates events (what happened), states (what the UI should show), and the bloc itself (the pure logic that transforms events into states). The result is testable state management that works for both simple and complex feature flows.
The BLoC ecosystem has two flavors: full BLoC (event-driven, using EventTransformers and on<Event> handlers) and Cubit (simpler, function-based with emit()). At iStoria we use both — Cubit for features with straightforward state transitions, BLoC for features with complex event flows, debouncing, or analytics requirements.
This guide covers structuring events, states, and blocs the way a production team does it — not the minimal tutorial version, but the patterns that survive in a 5M-user codebase.
Cubit is simpler. You call methods on it (loadLessons(), markComplete()) and those methods call emit() to update the state. Cubit is the right choice for features where the state transitions are driven by direct user actions — "user tapped this, emit that."
BLoC is event-driven. The UI dispatches events (LessonLoadRequested, LessonCompleted), and the BLoC transforms those events into states via registered handlers. BLoC is the right choice when you need:
restartable() to cancel the previous search when a new character arrives.The rule at iStoria: start with Cubit, upgrade to BLoC when you need event-level control. Most features never need to upgrade.
The most important principle: states should be complete snapshots of the UI. A widget should be able to render from the state alone, without combining it with other data.
The common pattern is a sealed class (or abstract class with subclasses):
abstract class LessonState {}
class LessonInitial extends LessonState {}
class LessonLoading extends LessonState {}
class LessonLoaded extends LessonState {
final List<Lesson> lessons;
final bool hasReachedMax;
LessonLoaded({required this.lessons, this.hasReachedMax = false});
}
class LessonError extends LessonState {
final String message;
LessonError(this.message);
}
Avoid mutable state inside a state class. Every state is immutable. Transitions produce new state objects, not mutations of existing ones.
A common mistake is representing every loading state as LessonLoading — which wipes the list when you pull-to-refresh. Instead, keep the loaded data and add a isRefreshing flag:
class LessonLoaded extends LessonState {
final List<Lesson> lessons;
final bool isRefreshing; // pull-to-refresh shows a spinner without clearing the list
final bool isPaginating; // infinite scroll shows a bottom spinner
}
This is the difference between an app that flickers on every refresh and one that feels smooth.
Events should describe what happened, not what to do. Good event names are past-tense or noun phrases: LessonLoadRequested, LessonCompleted, SearchQueryChanged. Bad names: LoadLessons, DoSomething.
Events carry the data the BLoC needs to process them:
class SearchQueryChanged extends LessonEvent {
final String query;
SearchQueryChanged(this.query);
}
Do not put UI concerns in events (no BuildContext, no TextEditingController). Events are pure data.
Event transformers control how incoming events are processed relative to each other. The most useful ones from package:bloc_concurrency:
restartable() — cancels the previous handler when a new event arrives. Use for search queries: typing a new character cancels the old search.droppable() — ignores new events while the handler is running. Use for submit buttons: prevent double-submission.sequential() — processes events one at a time in order. The default.concurrent() — processes all events concurrently.on<SearchQueryChanged>(_onSearchChanged, transformer: restartable());
on<SubmitPressed>(_onSubmit, transformer: droppable());
These transformers prevent an entire class of race-condition bugs. At iStoria, every search input uses restartable() and every submit action uses droppable().
BLoCs should not import each other directly. For cross-feature communication:
1. Listener Bloc — one BLoC subscribes to another's stream and reacts. Used when feature A needs to respond to state changes in feature B. 2. Repository as mediator — both BLoCs depend on a shared repository. When one writes, the other reads reactively. This is our preferred pattern at iStoria — it keeps BLoCs decoupled. 3. Event-based — one BLoC dispatches an event to another through a shared event bus. Use sparingly; it makes the flow hard to trace.
BLoC testing is one of the pattern's biggest wins. The bloc_test package lets you assert on state sequences:
blocTest<LessonCubit, LessonState>(
'emits [Loading, Loaded] when loadLessons succeeds',
build: () {
when(() => mockRepository.getLessons(any()))
.thenAnswer((_) async => const Right([testLesson]));
return LessonCubit(mockRepository);
},
act: (cubit) => cubit.loadLessons('course-1'),
expect: () => [isA<LessonLoading>(), isA<LessonLoaded>()],
);
This test runs in milliseconds, needs no device, and verifies the exact state sequence. This is why we invest in BLoC — the testability pays for the boilerplate.
lib/features/lesson/presentation/
├── bloc/
│ ├── lesson_bloc.dart # BLoC: event handlers, transformers
│ ├── lesson_event.dart # Events: what happened (past tense)
│ └── lesson_state.dart # States: complete UI snapshots
├── cubit/ # Use Cubit instead when the flow is simple
│ ├── lesson_cubit.dart
│ └── lesson_state.dart
├── pages/
│ └── lesson_page.dart # BlocProvider + UI
└── widgets/
├── lesson_list.dart
└── lesson_error_view.dart
// lesson_event.dart — EVENTS describe what happened
abstract class LessonEvent {}
class LessonLoadRequested extends LessonEvent {
final String courseId;
LessonLoadRequested(this.courseId);
}
class LessonRefreshRequested extends LessonEvent {
final String courseId;
LessonRefreshRequested(this.courseId);
}
class LessonCompleted extends LessonEvent {
final String lessonId;
LessonCompleted(this.lessonId);
}
class LessonSearchChanged extends LessonEvent {
final String query;
LessonSearchChanged(this.query);
}
// lesson_state.dart — STATES are complete, immutable UI snapshots
abstract class LessonState {}
class LessonInitial extends LessonState {}
class LessonLoading extends LessonState {}
class LessonLoaded extends LessonState {
final List<Lesson> lessons;
final bool isRefreshing;
final bool isPaginating;
final bool hasReachedMax;
const LessonLoaded({
required this.lessons,
this.isRefreshing = false,
this.isPaginating = false,
this.hasReachedMax = false,
});
LessonLoaded copyWith({
List<Lesson>? lessons,
bool? isRefreshing,
bool? isPaginating,
bool? hasReachedMax,
}) {
return LessonLoaded(
lessons: lessons ?? this.lessons,
isRefreshing: isRefreshing ?? this.isRefreshing,
isPaginating: isPaginating ?? this.isPaginating,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
);
}
}
class LessonError extends LessonState {
final String message;
final List<Lesson>? lastKnownLessons; // keep old data visible on error
LessonError(this.message, {this.lastKnownLessons});
}
// lesson_bloc.dart — THE BLOC: pure logic, event → state
class LessonBloc extends Bloc<LessonEvent, LessonState> {
final LessonRepository repository;
LessonBloc(this.repository) : super(LessonInitial()) {
on<LessonLoadRequested>(_onLoadRequested);
on<LessonRefreshRequested>(_onRefreshRequested);
on<LessonCompleted>(_onCompleted);
on<LessonSearchChanged>(_onSearchChanged, transformer: restartable());
}
Future<void> _onLoadRequested(
LessonLoadRequested event,
Emitter<LessonState> emit,
) async {
emit(LessonLoading());
final result = await repository.getLessons(event.courseId);
result.fold(
(failure) => emit(LessonError(failure.message)),
(lessons) => emit(LessonLoaded(lessons: lessons)),
);
}
Future<void> _onRefreshRequested(
LessonRefreshRequested event,
Emitter<LessonState> emit,
) async {
// Keep the existing data visible — just show the refresh indicator
final currentLessons = _currentLessons;
emit(LessonLoaded(lessons: currentLessons, isRefreshing: true));
final result = await repository.getLessons(event.courseId);
result.fold(
(failure) => emit(LessonError(failure.message, lastKnownLessons: currentLessons)),
(lessons) => emit(LessonLoaded(lessons: lessons)),
);
}
Future<void> _onCompleted(
LessonCompleted event,
Emitter<LessonState> emit,
) async {
final result = await repository.markComplete(event.lessonId);
result.fold(
(failure) => emit(LessonError(failure.message, lastKnownLessons: _currentLessons)),
(_) {
// Optimistic: update the local list immediately
if (state is LessonLoaded) {
final updated = _currentLessons.map((l) {
return l.id == event.lessonId ? l.copyWith(isCompleted: true) : l;
}).toList();
emit((state as LessonLoaded).copyWith(lessons: updated));
}
},
);
}
Future<void> _onSearchChanged(
LessonSearchChanged event,
Emitter<LessonState> emit,
) async {
if (event.query.isEmpty) return; // restartable() cancels stale searches
final result = await repository.searchLessons(event.query);
result.fold(
(failure) => emit(LessonError(failure.message)),
(lessons) => emit(LessonLoaded(lessons: lessons)),
);
}
List<Lesson> get _currentLessons =>
state is LessonLoaded ? (state as LessonLoaded).lessons : [];
}
// TEST — bloc_test verifies exact state sequences in milliseconds
void main() {
late LessonRepository mockRepository;
late LessonBloc bloc;
setUp(() {
mockRepository = MockLessonRepository();
bloc = LessonBloc(mockRepository);
});
blocTest<LessonBloc, LessonState>(
'emits [Loading, Loaded] on successful load',
build: () {
when(() => mockRepository.getLessons('c1'))
.thenAnswer((_) async => Right([testLesson]));
return bloc;
},
act: (b) => b.add(LessonLoadRequested('c1')),
wait: const Duration(milliseconds: 100),
expect: () => [
isA<LessonLoading>(),
isA<LessonLoaded>().having((s) => s.lessons.length, 'lessons', 1),
],
);
}
Start with Cubit for every feature. It is simpler (function calls + emit) and sufficient for most state transitions. Upgrade to full BLoC only when you need event transformers (debouncing, restartable search), complex event-to-state flows, or auditable event history. At iStoria, about 70% of features use Cubit and 30% use full BLoC.
Use the droppable() event transformer from package:bloc_concurrency on your submit event handler. It ignores new events while the handler is already running. This is a one-line fix that prevents an entire class of duplicate-submission bugs.
Store the last known data in your error state. Instead of a bare LessonError, include lastKnownLessons so the UI can show the old list with an error banner. Alternatively, keep the Loaded state and add an error message field — both patterns work, but never let an error blank out the screen.
Available for hire. Abdelrahman Saed is a Senior Mobile Engineer (Flutter) — open to full-time, fractional, contract, or advisory work. Hire me →
Book a 20-minute call · Download the CV (PDF) · See how I work