Muhammad Uzair
State Management

Flutter BLoC Pattern Tutorial: Zero to Production

Published: 2026-10-13 · 7 min read

A complete Flutter BLoC tutorial from first event to production: login flows, Cubits, event transformers, bloc_test testing, and pitfalls that break real apps.

Every Flutter developer eventually hits the wall where setState stops scaling. Screens need to share data, logic leaks into widgets, and a "simple" change breaks three features. The flutter bloc tutorial you are reading is the fix I wish someone had handed me earlier: a complete walkthrough from your first event to a production-ready BLoC architecture, with real code and the mistakes to avoid.

What BLoC Actually Is

BLoC (Business Logic Component) separates your app into three layers:

  1. Events — things that happened (button tapped, page loaded).
  2. States — what the UI should show right now.
  3. Bloc — the business logic that maps events to states.

Data flows in one direction: UI dispatches events → bloc processes them → bloc emits states → UI rebuilds. No bidirectional spaghetti. The flutter_bloc package gives you BlocProvider, BlocBuilder, and BlocListener to wire this into your widget tree.

Your First Bloc: A Counter (Done Right)

Add the dependency:

dependencies:
  flutter_bloc: ^8.1.0

Even the classic counter is worth doing properly, because the shape of it — event, state, bloc — repeats at every scale:

// counter_event.dart
sealed class CounterEvent {}
final class CounterIncremented extends CounterEvent {}
final class CounterDecremented extends CounterEvent {}
// counter_bloc.dart
import 'package:bloc/bloc.dart';
part 'counter_event.dart';

class CounterBloc extends Bloc<CounterEvent, int> {
  CounterBloc() : super(0) {
    on<CounterIncremented>((event, emit) => emit(state + 1));
    on<CounterDecremented>((event, emit) => emit(state - 1));
  }
}
// counter_screen.dart
class CounterScreen extends StatelessWidget {
  const CounterScreen({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (_) => CounterBloc(),
      child: Scaffold(
        appBar: AppBar(title: const Text('BLoC Counter')),
        body: Center(
          child: BlocBuilder<CounterBloc, int>(
            builder: (context, count) => Text('$count',
                style: Theme.of(context).textTheme.displayLarge),
          ),
        ),
        floatingActionButton: Column(
          mainAxisSize: MainAxisSize.min,
          children: [
            FloatingActionButton(
              onPressed: () => context
                  .read<CounterBloc>()
                  .add(CounterIncremented()),
              child: const Icon(Icons.add),
            ),
            const SizedBox(height: 8),
            FloatingActionButton(
              onPressed: () => context
                  .read<CounterBloc>()
                  .add(CounterDecremented()),
              child: const Icon(Icons.remove),
            ),
          ],
        ),
      ),
    );
  }
}

Key details: context.read<CounterBloc>() dispatches events without subscribing to rebuilds, while BlocBuilder rebuilds only when the state changes. Sealed event classes let the compiler warn you about unhandled cases.

A Real-World Bloc: Login Flow

Toy examples don't teach state design. A login flow does, because it has four genuinely different states:

sealed class LoginState {}
final class LoginInitial extends LoginState {}
final class LoginLoading extends LoginState {}
final class LoginSuccess extends LoginState {
  final String userId;
  LoginSuccess(this.userId);
}
final class LoginFailure extends LoginState {
  final String message;
  LoginFailure(this.message);
}

sealed class LoginEvent {}
final class LoginSubmitted extends LoginEvent {
  final String email;
  final String password;
  LoginSubmitted(this.email, this.password);
}
class LoginBloc extends Bloc<LoginEvent, LoginState> {
  final AuthRepository authRepository;

  LoginBloc(this.authRepository) : super(LoginInitial()) {
    on<LoginSubmitted>(_onSubmitted);
  }

  Future<void> _onSubmitted(
    LoginSubmitted event,
    Emitter<LoginState> emit,
  ) async {
    emit(LoginLoading());
    try {
      final user = await authRepository.login(event.email, event.password);
      emit(LoginSuccess(user.id));
    } on AuthException catch (e) {
      emit(LoginFailure(e.message));
    } catch (_) {
      emit(LoginFailure('Something went wrong. Please try again.'));
    }
  }
}

Notice the bloc talks to an AuthRepository, never to HTTP directly. BLoC is a state-management pattern, not a networking layer — keep the boundary clean.

The UI uses both builder and listener, each for its proper job:

BlocListener<LoginBloc, LoginState>(
  listener: (context, state) {
    if (state is LoginSuccess) {
      Navigator.of(context).pushReplacementNamed('/home');
    } else if (state is LoginFailure) {
      ScaffoldMessenger.of(context).showSnackBar(
        SnackBar(content: Text(state.message)),
      );
    }
  },
  child: BlocBuilder<LoginBloc, LoginState>(
    builder: (context, state) {
      final isLoading = state is LoginLoading;
      return ElevatedButton(
        onPressed: isLoading
            ? null
            : () => context.read<LoginBloc>().add(
                  LoginSubmitted(emailCtrl.text, passwordCtrl.text),
                ),
        child: isLoading
            ? const CircularProgressIndicator()
            : const Text('Sign in'),
      );
    },
  ),
);

The rule: BlocBuilder draws things, BlocListener does things (navigation, snackbars, analytics). Never navigate inside a builder.

Cubit: The Simpler Sibling

When your state is simple, a Cubit skips events and lets you call methods directly:

class ThemeCubit extends Cubit<ThemeMode> {
  ThemeCubit() : super(ThemeMode.system);

  void toggle() => emit(
        state == ThemeMode.dark ? ThemeMode.light : ThemeMode.dark,
      );
}

My working rule: reach for Cubit when state transitions are trivial (toggles, filters, selected tabs). Use Bloc when you have complex event flows, need to debounce or transform events, or want the audit trail that events provide.

Structuring BLoC in a Real App

For each feature, keep one folder:

lib/features/auth/
  bloc/
    login_bloc.dart
    login_event.dart
    login_state.dart
  view/
    login_screen.dart
  widgets/
    login_form.dart

Provide blocs at the right scope. Feature-level blocs live above the feature's navigator entry point; truly global blocs (auth session, theme) sit above MaterialApp. Two common mistakes: providing a bloc inside the widget that consumes it (recreates on every rebuild) and making everything global "just in case" (makes testing painful).

Access patterns at a glance:

Method Rebuilds on change? Use for
context.watch<Bloc>() Yes Reading state inside build
context.read<Bloc>() No Dispatching events, one-off reads
context.select((b) => b.state.x) On x only Avoiding rebuilds on unrelated changes

Testing Your Blocs

BLoC's payoff is testability. bloc_test lets you assert the exact state sequence for each event:

import 'package:bloc_test/bloc_test.dart';

blocTest<LoginBloc, LoginState>(
  'emits [LoginLoading, LoginSuccess] when credentials are valid',
  build: () => LoginBloc(FakeAuthRepository()),
  act: (bloc) => bloc.add(
    LoginSubmitted('a@b.com', 'secret123'),
  ),
  expect: () => [
    isA<LoginLoading>(),
    isA<LoginSuccess>(),
  ],
);

blocTest<LoginBloc, LoginState>(
  'emits [LoginLoading, LoginFailure] when credentials are invalid',
  build: () => LoginBloc(FakeAuthRepository(shouldFail: true)),
  act: (bloc) => bloc.add(
    LoginSubmitted('a@b.com', 'wrong'),
  ),
  expect: () => [
    isA<LoginLoading>(),
    isA<LoginFailure>(),
  ],
);

Run these against a fake repository, not your real API. Fast, deterministic, and they document the contract your UI relies on.

Bloc Concurrency: Handling Rapid Events

Real users tap fast. Two login taps in a row, or a search field firing on every keystroke, can spawn duplicate API calls or out-of-order results. The on<E> handler accepts an event transformer that controls this:

import 'package:bloc_concurrency/bloc_concurrency.dart';

class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(SearchInitial()) {
    // Debounce typing, cancel the in-flight request on each new keystroke.
    on<SearchQueryChanged>(
      _onQueryChanged,
      transformer: restartable(),
    );
    // Ignore duplicate submits while one is already running.
    on<LoginSubmitted>(
      _onSubmitted,
      transformer: droppable(),
    );
  }
}

The four transformers cover nearly every case:

Transformer Behavior Use for
sequential() (default) Processes events one at a time, in order Most flows
concurrent() Runs every event handler immediately Independent events
droppable() Drops new events while one is running Submit buttons, refresh actions
restartable() Cancels the running handler, starts the new one Search-as-you-type, filters

Combined with debounceTime from RxDart on the event stream, this is how you build search fields and pull-to-refresh flows that never double-fire.

Pitfalls to Avoid

  • Putting business logic in builders. If your BlocBuilder computes totals, filters lists, or validates forms, that logic is untestable and runs on every rebuild. Move it into the bloc or a selector.
  • Emitting states that don't describe UI. A state like LoginPasswordFieldFocused is noise. States should answer one question: "what should the screen look like?"
  • Forgetting to close blocs. If you create a bloc manually (LoginBloc() in a StatefulWidget), override dispose and call bloc.close(). BlocProvider handles this automatically when it creates the bloc.
  • One mega-bloc per app. A AppBloc with 40 events is setState with extra steps. One bloc per feature, composed together.
  • Ignoring event transformers. Rapid taps firing duplicate API calls? on<LoginSubmitted>(_onSubmitted, transformer: droppable()) from bloc_concurrency solves it in one line.

Closing

BLoC earns its reputation because the discipline compounds: events make intent explicit, states make UI predictable, and the repository boundary makes everything testable. Start with a Cubit where it's enough, graduate to full blocs for real flows, test the state sequences, and your app stays navigable at 100 screens the way it was at 10.

Building a Flutter app with serious state requirements and want it architected right from day one? Let's talk — this is the architecture I ship in production.

Frequently Asked Questions

What is the BLoC pattern in Flutter?

BLoC (Business Logic Component) separates an app into events (things that happened), states (what the UI should show), and the bloc (business logic mapping events to states). Data flows one way: UI dispatches events, the bloc processes them and emits states, and the UI rebuilds. The flutter_bloc package wires this into the widget tree with BlocProvider, BlocBuilder, and BlocListener.

When should I use BLoC instead of setState?

Use BLoC when screens need to share data, logic leaks into widgets, or simple changes start breaking multiple features. A login flow is a good example: it has four genuinely different states (initial, loading, success, failure) that a single boolean or setState cannot model cleanly.

How do I dispatch an event without causing rebuilds in Flutter BLoC?

Use context.read<YourBloc>().add(YourEvent()). Unlike context.watch, context.read dispatches events without subscribing the widget to rebuilds, while BlocBuilder handles the rebuilding when state changes.

Related Articles

← Back to all articles