Skip to content
AB

Why I Keep Choosing BLoC and Equatable for Production Flutter Apps

How growing Flutter features pushed me from widget-owned state to explicit events, predictable states, and value equality.

Ashish BhakhandPublished

I did not start using BLoC because every Flutter screen needs an event and state class. Many screens do not.

The need became clear when simple screens grew into real product features. A listing screen was no longer just loading or loaded. It could be refreshing, paginating, empty, filtered, partially loaded, or showing old data while a request failed. Several widgets could trigger the same action, and navigation or snackbars had to happen only once.

Keeping all of that inside a StatefulWidget worked at first. It became harder to answer a basic question later: what can happen on this screen, and what should the UI show after it happens?

That is the problem BLoC solves for me.

Where widget state started to hurt#

Consider a course listing screen:

Code
class _CoursesPageState extends State<CoursesPage> {
  var courses = <Course>[];
  var isLoading = false;
  String? error;
  var hasReachedEnd = false;

  Future<void> loadCourses() async {
    setState(() {
      isLoading = true;
      error = null;
    });

    try {
      final result = await repository.getCourses();
      setState(() {
        courses = result;
        hasReachedEnd = result.isEmpty;
      });
    } catch (exception) {
      setState(() => error = exception.toString());
    } finally {
      setState(() => isLoading = false);
    }
  }
}

This is manageable until refresh, search, pagination, retries, and analytics are added. Business decisions become tied to a widget lifecycle. Testing requires building the page, and a second page may implement the same behavior differently.

Events make user intent explicit#

With BLoC, I begin by naming what can happen:

Code
sealed class CoursesEvent extends Equatable {
  const CoursesEvent();

  @override
  List<Object?> get props => [];
}

final class CoursesRequested extends CoursesEvent {
  const CoursesRequested();
}

final class CoursesRefreshed extends CoursesEvent {
  const CoursesRefreshed();
}

final class CoursesNextPageRequested extends CoursesEvent {
  const CoursesNextPageRequested();
}

final class CourseSearchChanged extends CoursesEvent {
  const CourseSearchChanged(this.query);

  final String query;

  @override
  List<Object?> get props => [query];
}

The names describe intent rather than UI mechanics. A pull-to-refresh and a retry button can both dispatch CoursesRefreshed without duplicating the loading logic.

Events are especially valuable when a feature has several entry points. They provide one place to see the actions the feature accepts.

One state describes the whole screen#

I prefer a state that can answer every rendering question:

Code
enum CoursesStatus {
  initial,
  loading,
  success,
  failure,
}

class CoursesState extends Equatable {
  const CoursesState({
    this.status = CoursesStatus.initial,
    this.courses = const [],
    this.query = '',
    this.hasReachedEnd = false,
    this.errorMessage,
  });

  final CoursesStatus status;
  final List<Course> courses;
  final String query;
  final bool hasReachedEnd;
  final String? errorMessage;

  CoursesState copyWith({
    CoursesStatus? status,
    List<Course>? courses,
    String? query,
    bool? hasReachedEnd,
    String? errorMessage,
  }) {
    return CoursesState(
      status: status ?? this.status,
      courses: courses ?? this.courses,
      query: query ?? this.query,
      hasReachedEnd: hasReachedEnd ?? this.hasReachedEnd,
      errorMessage: errorMessage,
    );
  }

  @override
  List<Object?> get props => [
        status,
        courses,
        query,
        hasReachedEnd,
        errorMessage,
      ];
}

The state is an immutable snapshot. If a bug report says the page showed an error with existing courses, that combination can be reproduced in a test without recreating a sequence of setState calls.

Why Equatable matters#

Dart compares ordinary class instances by identity unless equality is implemented. Two states containing the same values would otherwise be different:

Code
final first = CoursesState(query: 'flutter');
final second = CoursesState(query: 'flutter');

print(first == second); // true with Equatable

Equatable provides value equality from the properties listed in props. This helps BLoC determine whether a meaningful new state was emitted, makes assertions readable, and prevents tests from comparing every field manually.

The important rule is simple: include every field that defines the state.

Code
@override
List<Object?> get props => [
      status,
      courses,
      query,
      hasReachedEnd,
      errorMessage,
    ];

Forgetting a field can hide a state change. Mutable lists can also make equality misleading, so state collections should be replaced rather than modified in place.

The BLoC owns transitions#

The BLoC connects an event to a use case and emits the next state:

Code
class CoursesBloc extends Bloc<CoursesEvent, CoursesState> {
  CoursesBloc(this._getCourses) : super(const CoursesState()) {
    on<CoursesRequested>(_onRequested);
    on<CoursesRefreshed>(_onRefreshed);
  }

  final GetCourses _getCourses;

  Future<void> _onRequested(
    CoursesRequested event,
    Emitter<CoursesState> emit,
  ) async {
    emit(state.copyWith(status: CoursesStatus.loading));

    final result = await _getCourses();

    result.fold(
      (failure) => emit(
        state.copyWith(
          status: CoursesStatus.failure,
          errorMessage: failure.message,
        ),
      ),
      (courses) => emit(
        state.copyWith(
          status: CoursesStatus.success,
          courses: courses,
          hasReachedEnd: courses.isEmpty,
        ),
      ),
    );
  }

  Future<void> _onRefreshed(
    CoursesRefreshed event,
    Emitter<CoursesState> emit,
  ) async {
    add(const CoursesRequested());
  }
}

In larger apps, I keep API parsing in models, data access in repositories, and business operations in use cases. The BLoC coordinates UI actions and state; it does not need to know HTTP details.

Builders render; listeners perform one-time actions#

Use BlocBuilder for UI:

Code
BlocBuilder<CoursesBloc, CoursesState>(
  builder: (context, state) {
    if (state.status == CoursesStatus.loading &&
        state.courses.isEmpty) {
      return const Center(child: CircularProgressIndicator());
    }

    if (state.status == CoursesStatus.failure &&
        state.courses.isEmpty) {
      return ErrorView(
        message: state.errorMessage!,
        onRetry: () => context
            .read<CoursesBloc>()
            .add(const CoursesRequested()),
      );
    }

    return CoursesList(courses: state.courses);
  },
)

Use BlocListener for navigation, snackbars, and dialogs:

Code
BlocListener<CheckoutBloc, CheckoutState>(
  listenWhen: (previous, current) =>
      previous.status != current.status,
  listener: (context, state) {
    if (state.status == CheckoutStatus.success) {
      Navigator.of(context).pushReplacementNamed('/receipt');
    }
  },
  child: const CheckoutForm(),
)

This distinction avoids navigation inside a build method. The flutter_bloc documentation describes BlocBuilder, BlocListener, selectors, and provider ownership in more detail.

Testing becomes a sequence of facts#

A BLoC test can express the behavior without rendering widgets:

Code
blocTest<CoursesBloc, CoursesState>(
  'loads courses successfully',
  build: () {
    when(() => getCourses()).thenAnswer(
      (_) async => Right([course]),
    );
    return CoursesBloc(getCourses);
  },
  act: (bloc) => bloc.add(const CoursesRequested()),
  expect: () => [
    const CoursesState(status: CoursesStatus.loading),
    CoursesState(
      status: CoursesStatus.success,
      courses: [course],
      hasReachedEnd: false,
    ),
  ],
);

This is the main reason BLoC continues to work for me in large codebases: feature behavior becomes a sequence that can be read, tested, and reviewed.

When I do not use BLoC#

I still use local widget state for:

  • whether a password is visible;
  • the selected tab in a small isolated widget;
  • an animation controller;
  • temporary expansion state;
  • values that no other layer needs.

BLoC earns its cost when a feature has asynchronous work, several actions, shared behavior, or state transitions worth testing. Equatable supports that design by making state equality predictable.

The packages are useful together, but the real improvement comes from making user intent and screen state explicit.

← Back to all writing