Flutter Cubit
Cubit Fundamentals
What is Cubit?
Cubit is a lightweight state management solution that's part of the BLoC (Business Logic Component) pattern. It provides a simple way to manage state using functions that emit new states.
Basic Setup
import 'package:flutter_bloc/flutter_bloc.dart';
// Define your state
abstract class CounterState {
final int count;
const CounterState(this.count);
}
class CounterInitial extends CounterState {
const CounterInitial() : super(0);
}
class CounterIncremented extends CounterState {
const CounterIncremented(int count) : super(count);
}
// Create your Cubit
class CounterCubit extends Cubit<CounterState> {
CounterCubit() : super(const CounterInitial());
void increment() {
emit(CounterIncremented(state.count + 1));
}
void decrement() {
emit(CounterIncremented(state.count - 1));
}
}
State Management Patterns
Simple State Management
// Simple state class
class UserState {
final String name;
final String email;
final bool isLoading;
const UserState({
this.name = '',
this.email = '',
this.isLoading = false,
});
UserState copyWith({
String? name,
String? email,
bool? isLoading,
}) {
return UserState(
name: name ?? this.name,
email: email ?? this.email,
isLoading: isLoading ?? this.isLoading,
);
}
}
// User Cubit
class UserCubit extends Cubit<UserState> {
UserCubit() : super(const UserState());
void updateName(String name) {
emit(state.copyWith(name: name));
}
void updateEmail(String email) {
emit(state.copyWith(email: email));
}
void setLoading(bool loading) {
emit(state.copyWith(isLoading: loading));
}
}
Async State Management
// Async state with loading, data, and error states
abstract class AsyncState<T> {
const AsyncState();
}
class AsyncInitial<T> extends AsyncState<T> {
const AsyncInitial();
}
class AsyncLoading<T> extends AsyncState<T> {
const AsyncLoading();
}
class AsyncData<T> extends AsyncState<T> {
final T data;
const AsyncData(this.data);
}
class AsyncError<T> extends AsyncState<T> {
final String message;
const AsyncError(this.message);
}
// Async Cubit
class UserListCubit extends Cubit<AsyncState<List<User>>> {
UserListCubit() : super(const AsyncInitial());
Future<void> fetchUsers() async {
emit(const AsyncLoading());
try {
final users = await userRepository.getUsers();
emit(AsyncData(users));
} catch (e) {
emit(AsyncError(e.toString()));
}
}
}
Widget Integration
BlocProvider
// Provide Cubit to widget tree
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CounterCubit(),
child: MaterialApp(
home: CounterPage(),
),
);
}
}
// Multiple providers
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (context) => CounterCubit()),
BlocProvider(create: (context) => UserCubit()),
BlocProvider(create: (context) => ThemeCubit()),
],
child: MaterialApp(
home: HomePage(),
),
);
}
}
BlocBuilder
class CounterPage extends StatelessWidget {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Counter')),
body: BlocBuilder<CounterCubit, CounterState>(
builder: (context, state) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Count: ${state.count}'),
ElevatedButton(
onPressed: () {
context.read<CounterCubit>().increment();
},
child: Text('Increment'),
),
],
),
);
},
),
);
}
}
BlocListener
class UserForm extends StatelessWidget {
Widget build(BuildContext context) {
return BlocListener<UserCubit, UserState>(
listener: (context, state) {
if (state.isLoading) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Loading...')),
);
}
},
child: Column(
children: [
TextField(
onChanged: (value) {
context.read<UserCubit>().updateName(value);
},
decoration: InputDecoration(labelText: 'Name'),
),
],
),
);
}
}
BlocConsumer
class UserProfile extends StatelessWidget {
Widget build(BuildContext context) {
return BlocConsumer<UserCubit, UserState>(
listener: (context, state) {
// Handle side effects
if (state.name.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Name cannot be empty')),
);
}
},
builder: (context, state) {
return Column(
children: [
Text('Name: ${state.name}'),
Text('Email: ${state.email}'),
if (state.isLoading) CircularProgressIndicator(),
],
);
},
);
}
}
Hook Widget Integration
What are Hook Widgets?
Hook widgets are a powerful way to reuse stateful logic across different widgets. When combined with Cubit, they provide a clean and efficient way to manage state and side effects. Hooks allow you to extract stateful logic into reusable functions, making your widgets more readable and testable.
Basic Hook Widget Setup
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
// Hook widget with Cubit
class CounterHookWidget extends HookWidget {
Widget build(BuildContext context) {
// Use useBloc hook to access Cubit
final counterCubit = useBloc<CounterCubit>();
final state = useBlocState<CounterCubit, CounterState>();
return Column(
children: [
Text('Count: ${state.count}'),
ElevatedButton(
onPressed: () => counterCubit.increment(),
child: Text('Increment'),
),
],
);
}
}
useBloc Hook
The useBloc
hook provides access to a Cubit instance from the widget tree.
class UserProfileHook extends HookWidget {
Widget build(BuildContext context) {
// Get Cubit instance
final userCubit = useBloc<UserCubit>();
// Get current state
final userState = useBlocState<UserCubit, UserState>();
return Column(
children: [
Text('Name: ${userState.name}'),
Text('Email: ${userState.email}'),
ElevatedButton(
onPressed: () => userCubit.updateName('New Name'),
child: Text('Update Name'),
),
],
);
}
}
useBlocState Hook
The useBlocState
hook automatically rebuilds the widget when the Cubit state changes.
class AsyncDataHook extends HookWidget {
Widget build(BuildContext context) {
final userCubit = useBloc<UserListCubit>();
final state = useBlocState<UserListCubit, AsyncState<List<User>>>();
// Automatically rebuilds when state changes
return switch (state) {
AsyncInitial() => Text('Initial state'),
AsyncLoading() => CircularProgressIndicator(),
AsyncData(:final data) => ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) => ListTile(
title: Text(data[index].name),
),
),
AsyncError(:final message) => Text('Error: $message'),
};
}
}
useBlocListener Hook
The useBlocListener
hook handles side effects when state changes, similar to BlocListener
.
class FormValidationHook extends HookWidget {
Widget build(BuildContext context) {
final formCubit = useBloc<FormCubit>();
final state = useBlocState<FormCubit, FormState>();
// Listen for state changes and show snackbars
useBlocListener<FormCubit, FormState>(
listener: (context, state) {
if (state.nameError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.nameError!)),
);
}
if (state.emailError != null) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.emailError!)),
);
}
},
);
return Column(
children: [
TextField(
onChanged: (value) => formCubit.updateName(value),
decoration: InputDecoration(
labelText: 'Name',
errorText: state.nameError,
),
),
TextField(
onChanged: (value) => formCubit.updateEmail(value),
decoration: InputDecoration(
labelText: 'Email',
errorText: state.emailError,
),
),
],
);
}
}
useBlocSelector Hook
The useBlocSelector
hook allows selective rebuilding based on specific state properties.
class OptimizedUserProfile extends HookWidget {
Widget build(BuildContext context) {
final userCubit = useBloc<UserCubit>();
// Only rebuilds when name changes
final name = useBlocSelector<UserCubit, UserState, String>(
selector: (state) => state.name,
);
// Only rebuilds when email changes
final email = useBlocSelector<UserCubit, UserState, String>(
selector: (state) => state.email,
);
// Only rebuilds when loading state changes
final isLoading = useBlocSelector<UserCubit, UserState, bool>(
selector: (state) => state.isLoading,
);
return Column(
children: [
Text('Name: $name'),
Text('Email: $email'),
if (isLoading) CircularProgressIndicator(),
ElevatedButton(
onPressed: () => userCubit.updateName('Updated Name'),
child: Text('Update Name'),
),
],
);
}
}
Combining Hooks with Cubit
You can combine multiple hooks to create complex state management logic.
class UserDashboard extends HookWidget {
Widget build(BuildContext context) {
final userCubit = useBloc<UserCubit>();
final themeCubit = useBloc<ThemeCubit>();
final userState = useBlocState<UserCubit, UserState>();
final themeState = useBlocState<ThemeCubit, ThemeState>();
// Use useEffect for side effects
useEffect(() {
// Fetch user data when widget mounts
userCubit.fetchUser();
return null; // No cleanup needed
}, []);
// Use useCallback for memoized callbacks
final handleLogout = useCallback(() {
userCubit.logout();
themeCubit.resetTheme();
}, [userCubit, themeCubit]);
return Scaffold(
appBar: AppBar(
title: Text('Dashboard'),
backgroundColor: themeState.primaryColor,
actions: [
IconButton(
onPressed: handleLogout,
icon: Icon(Icons.logout),
),
],
),
body: userState.isLoading
? Center(child: CircularProgressIndicator())
: Column(
children: [
Text('Welcome, ${userState.name}!'),
Text('Email: ${userState.email}'),
],
),
);
}
}
Custom Hooks with Cubit
Create reusable custom hooks that encapsulate Cubit logic.
// Custom hook for user authentication
UserState useUserAuth() {
final userCubit = useBloc<UserCubit>();
final state = useBlocState<UserCubit, UserState>();
useEffect(() {
// Check authentication status on mount
userCubit.checkAuthStatus();
return null;
}, []);
return state;
}
// Custom hook for form validation
(bool isValid, String? error) useFormValidation(FormCubit cubit) {
final state = useBlocState<FormCubit, FormState>();
return (state.isValid, state.nameError ?? state.emailError);
}
// Usage in widget
class LoginForm extends HookWidget {
Widget build(BuildContext context) {
final formCubit = useBloc<FormCubit>();
final (isValid, error) = useFormValidation(formCubit);
return Column(
children: [
TextField(
onChanged: (value) => formCubit.updateName(value),
decoration: InputDecoration(labelText: 'Name'),
),
if (error != null) Text(error, style: TextStyle(color: Colors.red)),
ElevatedButton(
onPressed: isValid ? () => formCubit.submit() : null,
child: Text('Submit'),
),
],
);
}
}
Async Operations with Hooks
Handle async operations and loading states efficiently.
class DataFetcher extends HookWidget {
Widget build(BuildContext context) {
final dataCubit = useBloc<DataCubit>();
final state = useBlocState<DataCubit, AsyncState<List<Data>>>();
// Auto-fetch data on mount
useEffect(() {
dataCubit.fetchData();
return null;
}, []);
// Handle refresh
final handleRefresh = useCallback(() {
dataCubit.fetchData();
}, [dataCubit]);
return RefreshIndicator(
onRefresh: handleRefresh,
child: switch (state) {
AsyncInitial() => Center(child: Text('Pull to refresh')),
AsyncLoading() => Center(child: CircularProgressIndicator()),
AsyncData(:final data) => ListView.builder(
itemCount: data.length,
itemBuilder: (context, index) => ListTile(
title: Text(data[index].title),
),
),
AsyncError(:final message) => Center(
child: Column(
children: [
Text('Error: $message'),
ElevatedButton(
onPressed: handleRefresh,
child: Text('Retry'),
),
],
),
),
},
);
}
}
Testing Hook Widgets with Cubit
Test hook widgets that use Cubit effectively.
void main() {
group('CounterHookWidget', () {
testWidgets('displays counter and increments on button press', (tester) async {
await tester.pumpWidget(
BlocProvider(
create: (context) => CounterCubit(),
child: MaterialApp(home: CounterHookWidget()),
),
);
expect(find.text('Count: 0'), findsOneWidget);
expect(find.text('Increment'), findsOneWidget);
await tester.tap(find.text('Increment'));
await tester.pump();
expect(find.text('Count: 1'), findsOneWidget);
});
});
group('UserProfileHook', () {
testWidgets('updates name when button is pressed', (tester) async {
await tester.pumpWidget(
BlocProvider(
create: (context) => UserCubit(),
child: MaterialApp(home: UserProfileHook()),
),
);
expect(find.text('Name: '), findsOneWidget);
await tester.tap(find.text('Update Name'));
await tester.pump();
expect(find.text('Name: New Name'), findsOneWidget);
});
});
}
Performance Benefits
Hook widgets with Cubit provide several performance benefits:
- Selective Rebuilding:
useBlocSelector
only rebuilds when specific state properties change - Memoized Callbacks:
useCallback
prevents unnecessary rebuilds - Efficient Side Effects:
useEffect
anduseBlocListener
handle side effects efficiently - Reduced Boilerplate: Less code compared to traditional
BlocBuilder
andBlocListener
combinations
Best Practices
- Use
useBlocSelector
for performance: Only rebuild widgets when necessary state changes - Extract logic into custom hooks: Create reusable hooks for common patterns
- Handle cleanup properly: Use
useEffect
return function for cleanup - Memoize expensive operations: Use
useMemoized
for expensive computations - Test thoroughly: Hook widgets can be tested like regular widgets
Advanced Patterns
Repository Pattern
// Repository interface
abstract class UserRepository {
Future<List<User>> getUsers();
Future<User> getUser(int id);
Future<void> createUser(User user);
}
// Repository implementation
class UserRepositoryImpl implements UserRepository {
final ApiClient apiClient;
UserRepositoryImpl(this.apiClient);
Future<List<User>> getUsers() async {
final response = await apiClient.get('/users');
return (response.data as List)
.map((json) => User.fromJson(json))
.toList();
}
Future<User> getUser(int id) async {
final response = await apiClient.get('/users/$id');
return User.fromJson(response.data);
}
Future<void> createUser(User user) async {
await apiClient.post('/users', data: user.toJson());
}
}
// Cubit with repository
class UserCubit extends Cubit<AsyncState<List<User>>> {
final UserRepository repository;
UserCubit(this.repository) : super(const AsyncInitial());
Future<void> fetchUsers() async {
emit(const AsyncLoading());
try {
final users = await repository.getUsers();
emit(AsyncData(users));
} catch (e) {
emit(AsyncError(e.toString()));
}
}
Future<void> createUser(User user) async {
try {
await repository.createUser(user);
await fetchUsers(); // Refresh the list
} catch (e) {
emit(AsyncError(e.toString()));
}
}
}
Dependency Injection
// Service locator pattern
class ServiceLocator {
static final ServiceLocator _instance = ServiceLocator._internal();
factory ServiceLocator() => _instance;
ServiceLocator._internal();
final Map<Type, dynamic> _services = {};
void register<T>(T service) {
_services[T] = service;
}
T get<T>() {
return _services[T] as T;
}
}
// Setup dependencies
void setupDependencies() {
final sl = ServiceLocator();
sl.register<ApiClient>(ApiClient());
sl.register<UserRepository>(UserRepositoryImpl(sl.get<ApiClient>()));
sl.register<UserCubit>(UserCubit(sl.get<UserRepository>()));
}
// Usage in widget
class MyApp extends StatelessWidget {
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ServiceLocator().get<UserCubit>(),
child: MaterialApp(home: HomePage()),
);
}
}
Cubit to Cubit Communication
// Parent Cubit
class AppCubit extends Cubit<AppState> {
final UserCubit userCubit;
final ThemeCubit themeCubit;
AppCubit({
required this.userCubit,
required this.themeCubit,
}) : super(const AppState()) {
// Listen to child cubits
userCubit.stream.listen((userState) {
emit(state.copyWith(user: userState));
});
themeCubit.stream.listen((themeState) {
emit(state.copyWith(theme: themeState));
});
}
void logout() {
userCubit.clearUser();
themeCubit.resetTheme();
}
}
// Child Cubits
class UserCubit extends Cubit<UserState> {
UserCubit() : super(const UserState());
void clearUser() {
emit(const UserState());
}
}
class ThemeCubit extends Cubit<ThemeState> {
ThemeCubit() : super(const ThemeState());
void resetTheme() {
emit(const ThemeState());
}
}
State Management Best Practices
Immutable States
// ✅ Good: Immutable state
class UserState {
final String name;
final String email;
final bool isLoading;
const UserState({
this.name = '',
this.email = '',
this.isLoading = false,
});
UserState copyWith({
String? name,
String? email,
bool? isLoading,
}) {
return UserState(
name: name ?? this.name,
email: email ?? this.email,
isLoading: isLoading ?? this.isLoading,
);
}
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is UserState &&
other.name == name &&
other.email == email &&
other.isLoading == isLoading;
}
int get hashCode => name.hashCode ^ email.hashCode ^ isLoading.hashCode;
}
// ❌ Bad: Mutable state
class BadUserState {
String name = '';
String email = '';
bool isLoading = false;
}
Error Handling
class UserCubit extends Cubit<AsyncState<List<User>>> {
final UserRepository repository;
UserCubit(this.repository) : super(const AsyncInitial());
Future<void> fetchUsers() async {
emit(const AsyncLoading());
try {
final users = await repository.getUsers();
emit(AsyncData(users));
} on NetworkException catch (e) {
emit(AsyncError('Network error: ${e.message}'));
} on ValidationException catch (e) {
emit(AsyncError('Validation error: ${e.message}'));
} catch (e) {
emit(AsyncError('Unexpected error: $e'));
}
}
void resetError() {
if (state is AsyncError) {
emit(const AsyncInitial());
}
}
}
Testing
// Unit tests for Cubit
void main() {
group('CounterCubit', () {
late CounterCubit counterCubit;
setUp(() {
counterCubit = CounterCubit();
});
tearDown(() {
counterCubit.close();
});
test('initial state is 0', () {
expect(counterCubit.state.count, equals(0));
});
test('increment increases count by 1', () {
counterCubit.increment();
expect(counterCubit.state.count, equals(1));
});
test('decrement decreases count by 1', () {
counterCubit.increment();
counterCubit.decrement();
expect(counterCubit.state.count, equals(0));
});
});
}
// Widget tests
void main() {
group('CounterPage', () {
testWidgets('shows counter and increment button', (tester) async {
await tester.pumpWidget(
BlocProvider(
create: (context) => CounterCubit(),
child: MaterialApp(home: CounterPage()),
),
);
expect(find.text('Count: 0'), findsOneWidget);
expect(find.text('Increment'), findsOneWidget);
});
testWidgets('increments counter when button is pressed', (tester) async {
await tester.pumpWidget(
BlocProvider(
create: (context) => CounterCubit(),
child: MaterialApp(home: CounterPage()),
),
);
await tester.tap(find.text('Increment'));
await tester.pump();
expect(find.text('Count: 1'), findsOneWidget);
});
});
}
Performance Optimization
Selective Rebuilding
// Use BlocSelector for selective rebuilding
class UserProfile extends StatelessWidget {
Widget build(BuildContext context) {
return Column(
children: [
// Only rebuilds when name changes
BlocSelector<UserCubit, UserState, String>(
selector: (state) => state.name,
builder: (context, name) {
return Text('Name: $name');
},
),
// Only rebuilds when email changes
BlocSelector<UserCubit, UserState, String>(
selector: (state) => state.email,
builder: (context, email) {
return Text('Email: $email');
},
),
// Only rebuilds when loading state changes
BlocSelector<UserCubit, UserState, bool>(
selector: (state) => state.isLoading,
builder: (context, isLoading) {
return isLoading ? CircularProgressIndicator() : SizedBox();
},
),
],
);
}
}
Memory Management
class UserCubit extends Cubit<AsyncState<List<User>>> {
final UserRepository repository;
StreamSubscription? _subscription;
UserCubit(this.repository) : super(const AsyncInitial());
Future<void> close() {
_subscription?.cancel();
return super.close();
}
void startListening() {
_subscription = repository.userStream.listen((users) {
emit(AsyncData(users));
});
}
}
Common Patterns
Form Management
class FormState {
final String name;
final String email;
final String? nameError;
final String? emailError;
final bool isValid;
const FormState({
this.name = '',
this.email = '',
this.nameError,
this.emailError,
this.isValid = false,
});
FormState copyWith({
String? name,
String? email,
String? nameError,
String? emailError,
bool? isValid,
}) {
return FormState(
name: name ?? this.name,
email: email ?? this.email,
nameError: nameError ?? this.nameError,
emailError: emailError ?? this.emailError,
isValid: isValid ?? this.isValid,
);
}
}
class FormCubit extends Cubit<FormState> {
FormCubit() : super(const FormState());
void updateName(String name) {
final nameError = _validateName(name);
final isValid = nameError == null && _validateEmail(state.email) == null;
emit(state.copyWith(
name: name,
nameError: nameError,
isValid: isValid,
));
}
void updateEmail(String email) {
final emailError = _validateEmail(email);
final isValid = _validateName(state.name) == null && emailError == null;
emit(state.copyWith(
email: email,
emailError: emailError,
isValid: isValid,
));
}
String? _validateName(String name) {
if (name.isEmpty) return 'Name is required';
if (name.length < 2) return 'Name must be at least 2 characters';
return null;
}
String? _validateEmail(String email) {
if (email.isEmpty) return 'Email is required';
if (!email.contains('@')) return 'Invalid email format';
return null;
}
}
Pagination
class PaginationState<T> {
final List<T> items;
final bool isLoading;
final bool hasReachedMax;
final String? error;
const PaginationState({
this.items = const [],
this.isLoading = false,
this.hasReachedMax = false,
this.error,
});
PaginationState<T> copyWith({
List<T>? items,
bool? isLoading,
bool? hasReachedMax,
String? error,
}) {
return PaginationState<T>(
items: items ?? this.items,
isLoading: isLoading ?? this.isLoading,
hasReachedMax: hasReachedMax ?? this.hasReachedMax,
error: error ?? this.error,
);
}
}
class PaginationCubit<T> extends Cubit<PaginationState<T>> {
final Future<List<T>> Function(int page) fetchData;
int _currentPage = 1;
PaginationCubit(this.fetchData) : super(const PaginationState());
Future<void> loadInitial() async {
emit(state.copyWith(isLoading: true, error: null));
try {
final items = await fetchData(1);
emit(state.copyWith(
items: items,
isLoading: false,
hasReachedMax: items.isEmpty,
));
_currentPage = 1;
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: e.toString(),
));
}
}
Future<void> loadMore() async {
if (state.isLoading || state.hasReachedMax) return;
emit(state.copyWith(isLoading: true));
try {
final newItems = await fetchData(_currentPage + 1);
if (newItems.isEmpty) {
emit(state.copyWith(
isLoading: false,
hasReachedMax: true,
));
} else {
emit(state.copyWith(
items: [...state.items, ...newItems],
isLoading: false,
));
_currentPage++;
}
} catch (e) {
emit(state.copyWith(
isLoading: false,
error: e.toString(),
));
}
}
}
References
- Flutter Bloc Package (pub.dev)
- Bloc Documentation (bloclibrary.dev)
- Cubit vs Bloc (bloclibrary.dev)
- Flutter State Management (flutter.dev)