As your app grows, setState stops scaling. Business logic mixes with UI code, sharing state across screens gets messy, and testing becomes difficult. Bloc solves this by separating state management into a clear pattern: events go in, states come out.
This guide covers flutter_bloc. If you are new to setState, start with Widgets first.
setState
Quick recap. setState works for local, simple state: a counter, a toggle, a text field. It breaks down when:
- Multiple widgets across different screens need the same state
- State depends on async operations (API calls, database reads)
- You want to test business logic separately from UI
That is where Bloc comes in. It lives outside your widgets and manages state independently.
Cubits
A Cubit is the simplest form of Bloc. It holds a single state value and exposes methods to change it. No events, just direct method calls. Start here for most use cases.
Add the package to your pubspec.yaml:
dependencies:
flutter_bloc: ^8.1.0Define a Cubit
A Cubit takes a type parameter for its state. The super constructor sets the initial value. Call emit to push a new state.
class CounterCubit extends Cubit<int> {
CounterCubit() : super(0); // initial state is 0
void increment() => emit(state + 1);
void decrement() => emit(state - 1);
void reset() => emit(0);
}Provide It
Wrap the part of your widget tree that needs the Cubit with BlocProvider. This creates the Cubit and makes it accessible to all widgets below it.
BlocProvider(
create: (context) => CounterCubit(),
child: CounterPage(),
)Use It
BlocBuilder rebuilds its child whenever the state changes. context.read accesses the Cubit without listening — use it in callbacks like onPressed, never inside build.
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: BlocBuilder<CounterCubit, int>(
builder: (context, count) {
return Text('Count: $count', style: TextStyle(fontSize: 24));
},
),
),
floatingActionButton: FloatingActionButton(
onPressed: () => context.read<CounterCubit>().increment(),
child: Icon(Icons.add),
),
);
}
}Bloc
A Bloc adds an event layer on top of Cubit. Instead of calling methods directly, you dispatch events. The Bloc listens for events and emits new states in response. This is more structured and easier to debug for complex async flows like authentication.
Define Events & States
Events describe what happened (user tapped login, user requested logout). States describe what the UI should show (loading, success, error). Using sealed classes lets you pattern-match every possible case.
// Events — what happened
sealed class AuthEvent {}
class AuthLoginRequested extends AuthEvent {
final String email;
final String password;
AuthLoginRequested({required this.email, required this.password});
}
class AuthLogoutRequested extends AuthEvent {}
// States — what to show
sealed class AuthState {}
class AuthInitial extends AuthState {}
class AuthLoading extends AuthState {}
class AuthAuthenticated extends AuthState {
final String userId;
AuthAuthenticated({required this.userId});
}
class AuthError extends AuthState {
final String message;
AuthError({required this.message});
}Define the Bloc
Register event handlers in the constructor with on<Event>. Each handler receives the event and an emit function to push new states.
class AuthBloc extends Bloc<AuthEvent, AuthState> {
final AuthRepository authRepository;
AuthBloc({required this.authRepository}) : super(AuthInitial()) {
on<AuthLoginRequested>(_onLogin);
on<AuthLogoutRequested>(_onLogout);
}
Future<void> _onLogin(
AuthLoginRequested event,
Emitter<AuthState> emit,
) async {
emit(AuthLoading());
try {
final userId = await authRepository.login(
email: event.email,
password: event.password,
);
emit(AuthAuthenticated(userId: userId));
} catch (e) {
emit(AuthError(message: e.toString()));
}
}
Future<void> _onLogout(
AuthLogoutRequested event,
Emitter<AuthState> emit,
) async {
await authRepository.logout();
emit(AuthInitial());
}
}Use the Bloc
Provide it the same way as a Cubit. Dispatch events with .add() and react to states with BlocBuilder.
// Provide
BlocProvider(
create: (context) => AuthBloc(authRepository: AuthRepository()),
child: LoginPage(),
)
// Dispatch an event
context.read<AuthBloc>().add(
AuthLoginRequested(email: email, password: password),
);
// React to state changes
BlocBuilder<AuthBloc, AuthState>(
builder: (context, state) {
if (state is AuthLoading) return CircularProgressIndicator();
if (state is AuthAuthenticated) return Text('Welcome!');
if (state is AuthError) return Text(state.message);
return LoginForm();
},
)BlocListener
BlocListener is for side effects that should happen once per state change: navigating to another screen, showing a snackbar, or starting a timer. Unlike BlocBuilder, it does not rebuild the UI. It just runs a callback.
BlocListener<AuthBloc, AuthState>(
listener: (context, state) {
if (state is AuthAuthenticated) {
context.go('/home');
}
if (state is AuthError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message)),
);
}
},
child: LoginForm(),
)Need both building and listening? Use BlocConsumer. It combines BlocBuilder and BlocListener in one widget.
MultiBlocProvider
Most apps need multiple Blocs (auth, theme, cart, etc.). Instead of nesting providers inside each other, use MultiBlocProvider to keep things flat and readable.
MultiBlocProvider(
providers: [
BlocProvider(create: (context) => AuthBloc(authRepository: AuthRepository())),
BlocProvider(create: (context) => ThemeCubit()),
BlocProvider(create: (context) => CartCubit()),
],
child: MyApp(),
)When to Use What
- setState — local UI state. A toggle, a counter, a form field. State lives in one widget only.
- Cubit — shared state or async logic with simple transitions. Most screens in your app.
- Bloc — complex async flows with multiple steps where you want a clear audit trail of what happened and why. Authentication, checkout, multi-step forms.
Start with Cubit. Move to Bloc when you need the event layer. Most real apps use a mix of both.
Other popular options exist (Riverpod, Provider, GetX). This guide covers Bloc because it scales well, has strong tooling, and makes testing straightforward. Pick one approach and stay consistent across your app.