Skip to content

Bloc Patterns

Best practices and common patterns for using Fasq with Bloc. These patterns will help you build maintainable and performant applications using the Bloc architecture.

Organize cubits by feature or domain:

user_cubits.dart
class UsersQueryCubit extends QueryCubit<List<User>> {
@override
String get key => 'users';
@override
Future<List<User>> Function() get queryFn => () => api.fetchUsers();
}
class UserQueryCubit extends QueryCubit<User> {
final String userId;
UserQueryCubit(this.userId);
@override
String get key => 'user:$userId';
@override
Future<User> Function() get queryFn => () => api.fetchUser(userId);
}
class CreateUserMutationCubit extends MutationCubit<User, Map<String, String>> {
@override
Future<User> Function(Map<String, String> variables) get mutationFn =>
(data) => api.createUser(data);
}
class UpdateUserMutationCubit extends MutationCubit<User, User> {
@override
Future<User> Function(User variables) get mutationFn =>
(user) => api.updateUser(user);
}
class DeleteUserMutationCubit extends MutationCubit<void, String> {
@override
Future<void> Function(String variables) get mutationFn =>
(userId) => api.deleteUser(userId);
}

Create cubits that depend on other cubits:

class AuthQueryCubit extends QueryCubit<User?> {
@override
String get key => 'auth';
@override
Future<User?> Function() get queryFn => () => api.getCurrentUser();
}
class UserPostsQueryCubit extends QueryCubit<List<Post>> {
final String userId;
UserPostsQueryCubit(this.userId);
@override
String get key => 'posts:user:$userId';
@override
Future<List<Post>> Function() get queryFn => () => api.fetchUserPosts(userId);
@override
QueryOptions? get options => QueryOptions(
enabled: () {
final authCubit = QueryClient().getQueryByKey<User?>('auth');
return authCubit?.state.hasData == true && authCubit?.state.data != null;
},
);
}

For complex screens requiring data from multiple sources, you have two main options:

Use FasqSubscriptionMixin to subscribe to multiple queries within a single Bloc and emit a unified state. This keeps your business logic testable and your UI clean.

See the Composition Guide for details.

class DashboardCubit extends Cubit<DashboardState> with FasqSubscriptionMixin {
DashboardCubit() : super(DashboardState.initial()) {
final userQuery = client.getQuery<User>('user'.toQueryKey(), ...);
final postsQuery = client.getQuery<List<Post>>('posts'.toQueryKey(), ...);
subscribeToQuery(userQuery, (state) {
emit(state.copyWith(user: state.data));
});
subscribeToQuery(postsQuery, (state) {
emit(state.copyWith(posts: state.data));
});
}
}

For simple layouts where independent widgets need independent data, use nested BlocBuilders or MultiBlocProvider:

Create computed state from other cubits:

class UsersStatsCubit extends Cubit<UsersStats> {
UsersStatsCubit() : super(UsersStats.empty());
void updateStats(List<User> users) {
emit(UsersStats(
totalUsers: users.length,
activeUsers: users.where((user) => user.isActive).length,
inactiveUsers: users.where((user) => !user.isActive).length,
));
}
}
class UsersStats {
final int totalUsers;
final int activeUsers;
final int inactiveUsers;
UsersStats({
required this.totalUsers,
required this.activeUsers,
required this.inactiveUsers,
});
factory UsersStats.empty() => UsersStats(
totalUsers: 0,
activeUsers: 0,
inactiveUsers: 0,
);
}
class UsersScreenWithStats extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (context) => UsersQueryCubit()),
BlocProvider(create: (context) => UsersStatsCubit()),
],
child: BlocListener<UsersQueryCubit, QueryState<List<User>>>(
listener: (context, state) {
if (state.hasData) {
context.read<UsersStatsCubit>().updateStats(state.data!);
}
},
child: BlocBuilder<UsersStatsCubit, UsersStats>(
builder: (context, stats) {
return Column(
children: [
Text('Total Users: ${stats.totalUsers}'),
Text('Active Users: ${stats.activeUsers}'),
Text('Inactive Users: ${stats.inactiveUsers}'),
],
);
},
),
),
);
}
}

Create a global error handler for mutations:

class GlobalErrorHandler {
static void handleError(BuildContext context, Object error) {
// Log error
print('Global error: $error');
// Show error snackbar
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('An error occurred: $error'),
backgroundColor: Colors.red,
),
);
}
}
class CreateUserMutationCubit extends MutationCubit<User, Map<String, String>> {
CreateUserMutationCubit() : super(
mutationFn: (data) => api.createUser(data),
options: MutationOptions(
onError: (error) {
// Handle error globally
GlobalErrorHandler.handleError(context, error);
},
),
);
}

Implement retry logic for failed queries:

class UsersQueryCubit extends QueryCubit<List<User>> {
@override
String get key => 'users';
@override
Future<List<User>> Function() get queryFn => () => api.fetchUsers();
@override
QueryOptions? get options => QueryOptions(
onError: (error) {
Future.delayed(Duration(seconds: 2), () {
refetch();
});
},
);
}

Preload data for better performance:

class CacheWarmerCubit extends Cubit<void> {
CacheWarmerCubit() : super(null);
void warmCache() {
// Warm cache on app start
QueryClient().prefetchQuery('users', () => api.fetchUsers());
QueryClient().prefetchQuery('posts', () => api.fetchPosts());
}
}
class AppInitializer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => CacheWarmerCubit()..warmCache(),
child: MaterialApp(
home: HomeScreen(),
),
);
}
}

Implement smart cache invalidation:

class CacheInvalidationService {
static void invalidateUserCache(String userId) {
// Invalidate specific user
QueryClient().invalidateQuery('user:$userId');
// Invalidate user list if it might be affected
QueryClient().invalidateQuery('users');
// Invalidate user posts
QueryClient().invalidateQuery('posts:user:$userId');
}
}
class UpdateUserMutationCubit extends MutationCubit<User, User> {
UpdateUserMutationCubit() : super(
mutationFn: (user) => api.updateUser(user),
options: MutationOptions(
onSuccess: (updatedUser) {
CacheInvalidationService.invalidateUserCache(updatedUser.id);
},
),
);
}

Manage form state with Bloc:

class CreateUserFormCubit extends Cubit<CreateUserFormState> {
CreateUserFormCubit() : super(CreateUserFormState.initial());
void updateName(String name) {
emit(state.copyWith(
name: name,
isValid: name.isNotEmpty && state.email.isNotEmpty,
nameError: name.isEmpty ? 'Name is required' : null,
));
}
void updateEmail(String email) {
emit(state.copyWith(
email: email,
isValid: state.name.isNotEmpty && email.isNotEmpty,
emailError: email.isEmpty ? 'Email is required' : null,
));
}
void reset() {
emit(CreateUserFormState.initial());
}
}
class CreateUserFormState {
final String name;
final String email;
final bool isValid;
final String? nameError;
final String? emailError;
CreateUserFormState({
required this.name,
required this.email,
required this.isValid,
this.nameError,
this.emailError,
});
factory CreateUserFormState.initial() => CreateUserFormState(
name: '',
email: '',
isValid: false,
);
CreateUserFormState copyWith({
String? name,
String? email,
bool? isValid,
String? nameError,
String? emailError,
}) {
return CreateUserFormState(
name: name ?? this.name,
email: email ?? this.email,
isValid: isValid ?? this.isValid,
nameError: nameError,
emailError: emailError,
);
}
}
class CreateUserForm extends StatelessWidget {
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
providers: [
BlocProvider(create: (context) => CreateUserFormCubit()),
BlocProvider(create: (context) => CreateUserMutationCubit()),
],
child: BlocBuilder<CreateUserFormCubit, CreateUserFormState>(
builder: (context, formState) {
return BlocBuilder<CreateUserMutationCubit, MutationState<User>>(
builder: (context, mutationState) {
return Form(
child: Column(
children: [
TextFormField(
decoration: InputDecoration(labelText: 'Name'),
onChanged: (value) {
context.read<CreateUserFormCubit>().updateName(value);
},
validator: (value) => formState.nameError,
),
TextFormField(
decoration: InputDecoration(labelText: 'Email'),
onChanged: (value) {
context.read<CreateUserFormCubit>().updateEmail(value);
},
validator: (value) => formState.emailError,
),
ElevatedButton(
onPressed: formState.isValid && !mutationState.isLoading
? () {
final mutationCubit = context.read<CreateUserMutationCubit>();
mutationCubit.mutate({
'name': formState.name,
'email': formState.email,
});
}
: null,
child: mutationState.isLoading
? CircularProgressIndicator()
: Text('Create User'),
),
],
),
);
},
);
},
),
);
}
}

Load data based on route parameters:

class UserDetailScreen extends StatelessWidget {
final String userId;
const UserDetailScreen({required this.userId});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => UserQueryCubit(userId),
child: Scaffold(
appBar: AppBar(title: Text('User Details')),
body: BlocBuilder<UserQueryCubit, QueryState<User>>(
builder: (context, state) {
if (state.isLoading) return CircularProgressIndicator();
if (state.hasError) return Text('Error: ${state.error}');
if (state.hasData) return UserDetailsView(user: state.data!);
return SizedBox();
},
),
),
);
}
}

Handle deep links with proper data loading:

class DeepLinkHandler {
static void handleDeepLink(BuildContext context, String path) {
final segments = path.split('/');
if (segments.length >= 2) {
final resource = segments[1];
final id = segments.length >= 3 ? segments[2] : null;
switch (resource) {
case 'users':
if (id != null) {
QueryClient().prefetchQuery('user:$id', () => api.fetchUser(id));
} else {
QueryClient().prefetchQuery('users', () => api.fetchUsers());
}
break;
case 'posts':
if (id != null) {
QueryClient().prefetchQuery('post:$id', () => api.fetchPost(id));
} else {
QueryClient().prefetchQuery('posts', () => api.fetchPosts());
}
break;
}
}
}
}

Create mock cubits for testing:

test_helpers.dart
class MockUsersQueryCubit extends QueryCubit<List<User>> {
@override
String get key => 'users';
@override
Future<List<User>> Function() get queryFn => () => Future.value([
User(id: '1', name: 'Test User 1'),
User(id: '2', name: 'Test User 2'),
]);
}
class MockCreateUserMutationCubit extends MutationCubit<User, Map<String, String>> {
@override
Future<User> Function(Map<String, String> variables) get mutationFn =>
(data) => Future.value(User(
id: '3',
name: data['name']!,
email: data['email']!,
));
}
// In your test
void main() {
testWidgets('should display users', (tester) async {
await tester.pumpWidget(
MultiBlocProvider(
providers: [
BlocProvider<QueryCubit<List<User>>, QueryState<List<User>>>(
create: (context) => MockUsersQueryCubit(),
),
],
child: MaterialApp(home: UsersScreen()),
),
);
await tester.pumpAndSettle();
expect(find.text('Test User 1'), findsOneWidget);
expect(find.text('Test User 2'), findsOneWidget);
});
}

Create utilities for testing cubits:

class CubitTestHelper {
static Future<void> pumpWidgetWithCubits(
WidgetTester tester,
Widget child,
List<BlocProvider> providers,
) async {
await tester.pumpWidget(
MultiBlocProvider(
providers: providers,
child: MaterialApp(home: child),
),
);
}
static Future<void> waitForCubitState<T extends Cubit<S>, S>(
WidgetTester tester,
T cubit,
S expectedState,
) async {
await tester.pump();
while (cubit.state != expectedState) {
await tester.pump(Duration(milliseconds: 100));
}
}
}
// Usage in tests
void main() {
testWidgets('should create user successfully', (tester) async {
final createUserCubit = MockCreateUserMutationCubit();
await CubitTestHelper.pumpWidgetWithCubits(
tester,
CreateUserForm(),
[
BlocProvider<MutationCubit<User, Map<String, String>>, MutationState<User>>(
create: (context) => createUserCubit,
),
],
);
await tester.enterText(find.byType(TextFormField).first, 'Test User');
await tester.enterText(find.byType(TextFormField).last, 'test@example.com');
await tester.tap(find.byType(ElevatedButton));
await tester.pumpAndSettle();
expect(createUserCubit.state.isSuccess, isTrue);
expect(createUserCubit.state.data?.name, equals('Test User'));
});
}

Implement lazy loading for large datasets:

class PaginatedUsersCubit extends Cubit<List<User>> {
PaginatedUsersCubit() : super([]);
Future<void> loadPage(int page) async {
final users = await api.fetchUsersPage(page: page, limit: 20);
emit([...state, ...users]);
}
Future<void> loadAllPages() async {
int currentPage = 1;
List<User> allUsers = [];
while (true) {
final users = await api.fetchUsersPage(page: currentPage, limit: 20);
allUsers.addAll(users);
if (users.length < 20) break; // Last page
currentPage++;
}
emit(allUsers);
}
}

Implement debounced search to avoid excessive API calls:

class SearchQueryCubit extends Cubit<String> {
Timer? _debounceTimer;
SearchQueryCubit() : super('');
void updateQuery(String query) {
_debounceTimer?.cancel();
_debounceTimer = Timer(Duration(milliseconds: 500), () {
emit(query);
});
}
@override
Future<void> close() {
_debounceTimer?.cancel();
return super.close();
}
}
class SearchResultsCubit extends QueryCubit<List<Post>> {
SearchResultsCubit(String query) : super(
queryKey: 'search:$query',
queryFn: () => api.searchPosts(query),
options: QueryOptions(
enabled: query.isNotEmpty && query.length >= 2,
),
);
}
  1. Organize cubits by feature - Keep related cubits together
  2. Use meaningful names - Make cubit names descriptive
  3. Handle errors gracefully - Implement proper error handling
  4. Optimize cache usage - Use appropriate staleTime and cacheTime
  5. Test thoroughly - Create comprehensive tests for cubits
  6. Use type safety - Leverage generic types for compile-time safety
  7. Document complex logic - Add comments for complex cubit logic