← Flutter Reference

Flutter BLoC Architecture: Structuring Events, States & Logic

State Management · Advanced

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.

BLoC vs. Cubit: When to Use Which

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:

  • Event debouncing or deduplication — e.g., search-as-you-type with a 300ms debounce.
  • Event transformers — e.g., restartable() to cancel the previous search when a new character arrives.
  • Complex event-to-state flows — one event triggers multiple states (loading → success), or multiple events interact.
  • Auditable event history — the event stream is loggable for debugging and analytics.

The rule at iStoria: start with Cubit, upgrade to BLoC when you need event-level control. Most features never need to upgrade.

Structuring States

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.

The "Loading Inside Loaded" Pattern

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.

Structuring Events

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

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().

BLoC-to-BLoC Communication

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.

Testing BLoCs

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.

Recommended folder structure

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

Code example

// 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),
    ],
  );
}

Implementation checklist

  • Start with Cubit for simple features — upgrade to full BLoC only when you need event transformers or complex event flows.
  • Define states as immutable, complete UI snapshots — a widget should be able to render from the state alone.
  • Name events as past-tense descriptions of what happened (LessonLoadRequested), not imperative commands (LoadLessons).
  • Add isRefreshing / isPaginating flags to Loaded states so loading does not wipe existing data from the UI.
  • Apply event transformers: restartable() for search inputs, droppable() for submit actions, to prevent race conditions.
  • Keep BLoCs decoupled — use shared repositories as mediators rather than direct BLoC-to-BLoC imports.
  • Write bloc_test cases for every state transition — this is the primary payoff of the BLoC pattern.

Common mistakes

  • Using LessonLoading for pull-to-refresh, which clears the list and causes UI flicker instead of using isRefreshing on the Loaded state.
  • Putting BuildContext or TextEditingController in events, coupling the BLoC to the widget layer and making it untestable.
  • Forgetting to apply event transformers on search and submit handlers, leading to race conditions and double-submissions.
  • Making BLoCs import each other directly, creating tight coupling between features that should be independent.
  • Mutating state objects in place instead of creating new immutable instances via copyWith, causing the UI not to rebuild.

Related definitions

Related reading

Related case studies

FAQ

Should I use BLoC or Cubit for my Flutter app?

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.

How do I prevent double-submission with 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.

How do I keep old data visible during an error?

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