Flutter'da BLoC ve Cubit: İleri Seviye State Management
bloc paketi, Flutter'da state management için en yaygın kurumsal çözümdür. Cubit, durumu fonksiyon çağrısıyla değiştirirken; BLoC, event-state dönüşümü modeliyle daha sıkı bir mimari sunar. Bu yazıda ikisi arasındaki farkı ve gerçek dünya senaryolarında nasıl kullanılacağını inceleyeceğiz.
Cubit: Basit ve Etkili
Cubit, BLoC'un daha basit versiyonudur. State'i doğrudan fonksiyon çağrısıyla değiştirir, event sınıfı gerekmez:
// State — freezed ile sealed class
@freezed
class CartState with _$CartState {
const CartState._(); // private — extension metotlar için
const factory CartState({
@Default([]) List<CartItem> items,
@Default(false) bool isLoading,
String? error,
}) = _CartState;
double get totalPrice =>
items.fold(0, (sum, item) => sum + item.product.price * item.quantity);
int get itemCount => items.fold(0, (sum, item) => sum + item.quantity);
bool get isEmpty => items.isEmpty;
}
// Cubit
@injectable
class CartCubit extends Cubit<CartState> {
final CartRepository _repo;
CartCubit(this._repo) : super(const CartState());
Future<void> load() async {
emit(state.copyWith(isLoading: true, error: null));
try {
final items = await _repo.getCartItems();
emit(state.copyWith(items: items, isLoading: false));
} catch (e) {
emit(state.copyWith(isLoading: false, error: e.toString()));
}
}
Future<void> addItem(Product product, {int quantity = 1}) async {
final existing = state.items.indexWhere((i) => i.product.id == product.id);
List<CartItem> updated;
if (existing >= 0) {
updated = List.of(state.items)
..[existing] = state.items[existing].copyWith(
quantity: state.items[existing].quantity + quantity,
);
} else {
updated = [...state.items, CartItem(product: product, quantity: quantity)];
}
emit(state.copyWith(items: updated));
await _repo.saveCartItems(updated); // optimistic — UI önce güncellenir
}
Future<void> removeItem(String productId) async {
final updated = state.items.where((i) => i.product.id != productId).toList();
emit(state.copyWith(items: updated));
await _repo.saveCartItems(updated);
}
Future<void> clear() async {
emit(const CartState());
await _repo.clearCart();
}
}
BLoC: Event-Driven Mimari
Her kullanıcı eylemi için ayrı event sınıfı tanımlanır. Bu, daha net bir API ve daha iyi geçmiş takibi sağlar:
// Events
@freezed
class SearchEvent with _$SearchEvent {
const factory SearchEvent.started(String query) = _Started;
const factory SearchEvent.filterChanged(ProductFilter filter) = _FilterChanged;
const factory SearchEvent.sortChanged(SortOption sort) = _SortChanged;
const factory SearchEvent.loadMoreRequested() = _LoadMore;
const factory SearchEvent.refreshRequested() = _Refresh;
}
// State
@freezed
class SearchState with _$SearchState {
const factory SearchState.initial() = _Initial;
const factory SearchState.loading({String? query}) = _Loading;
const factory SearchState.success({
required String query,
required List<Product> products,
required bool hasMore,
required ProductFilter filter,
required SortOption sort,
}) = _Success;
const factory SearchState.empty(String query) = _Empty;
const factory SearchState.failure(String message) = _Failure;
}
// BLoC
@injectable
class SearchBloc extends Bloc<SearchEvent, SearchState> {
final SearchProducts _searchProducts;
Timer? _debounce;
SearchBloc(this._searchProducts) : super(const SearchState.initial()) {
on<_Started>(_onStarted, transformer: droppable());
on<_FilterChanged>(_onFilterChanged);
on<_SortChanged>(_onSortChanged);
on<_LoadMore>(_onLoadMore, transformer: droppable());
on<_Refresh>(_onRefresh, transformer: droppable());
}
Future<void> _onStarted(_Started event, Emitter<SearchState> emit) async {
if (event.query.trim().isEmpty) {
emit(const SearchState.initial());
return;
}
emit(SearchState.loading(query: event.query));
await _fetchAndEmit(emit, query: event.query);
}
Future<void> _onFilterChanged(
_FilterChanged event, Emitter<SearchState> emit) async {
final current = state;
if (current is! _Success) return;
emit(SearchState.loading(query: current.query));
await _fetchAndEmit(emit,
query: current.query,
filter: event.filter,
sort: current.sort);
}
Future<void> _onLoadMore(
_LoadMore event, Emitter<SearchState> emit) async {
final current = state;
if (current is! _Success || !current.hasMore) return;
final result = await _searchProducts(SearchParams(
query: current.query,
filter: current.filter,
sort: current.sort,
page: (current.products.length ~/ 20) + 1,
));
result.fold(
(failure) => emit(SearchState.failure(failure.message)),
(newProducts) => emit(current.copyWith(
products: [...current.products, ...newProducts],
hasMore: newProducts.length == 20,
)),
);
}
Future<void> _fetchAndEmit(Emitter<SearchState> emit, {
required String query,
ProductFilter? filter,
SortOption? sort,
}) async {
final result = await _searchProducts(SearchParams(
query: query,
filter: filter ?? const ProductFilter(),
sort: sort ?? SortOption.relevance,
));
result.fold(
(failure) => emit(SearchState.failure(failure.message)),
(products) => products.isEmpty
? emit(SearchState.empty(query))
: emit(SearchState.success(
query: query,
products: products,
hasMore: products.length == 20,
filter: filter ?? const ProductFilter(),
sort: sort ?? SortOption.relevance,
)),
);
}
@override
Future<void> close() {
_debounce?.cancel();
return super.close();
}
}
BlocBuilder, BlocListener ve BlocConsumer
// BlocBuilder: sadece UI rebuild
BlocBuilder<CartCubit, CartState>(
buildWhen: (prev, curr) => prev.itemCount != curr.itemCount,
builder: (context, state) => Badge(
count: state.itemCount,
child: const Icon(Icons.shopping_cart),
),
)
// BlocListener: yan etki (navigasyon, snackbar, dialog)
BlocListener<OrderCubit, OrderState>(
listenWhen: (prev, curr) => prev.status != curr.status,
listener: (context, state) {
state.whenOrNull(
success: (order) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Sipariş #${order.id} oluşturuldu!')));
context.go('/orders/${order.id}');
},
failure: (msg) => showDialog(
context: context,
builder: (_) => AlertDialog(title: const Text('Hata'), content: Text(msg)),
),
);
},
child: const OrderFormContent(),
)
// BlocConsumer: hem UI hem yan etki
BlocConsumer<SearchBloc, SearchState>(
listenWhen: (p, c) => c is _Failure,
listener: (context, state) {
if (state is _Failure) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(state.message), backgroundColor: Colors.red));
}
},
buildWhen: (p, c) => c is! _Failure,
builder: (context, state) => state.when(
initial: () => const SearchPrompt(),
loading: (query) => const SearchLoadingShimmer(),
success: (query, products, hasMore, filter, sort) =>
SearchResultsView(products: products, hasMore: hasMore),
empty: (query) => SearchEmptyView(query: query),
failure: (_) => const SearchErrorPlaceholder(),
),
)
MultiBlocProvider ile Provider Hiyerarşisi
// Uygulama genelinde gerekli BLoC'ları en üstte sağla
MultiBlocProvider(
providers: [
BlocProvider(create: (_) => getIt<AuthCubit>()..checkSession()),
BlocProvider(create: (_) => getIt<CartCubit>()..load()),
BlocProvider(create: (_) => getIt<ThemeCubit>()),
],
child: BlocBuilder<ThemeCubit, ThemeState>(
builder: (context, themeState) => MaterialApp.router(
theme: themeState.lightTheme,
darkTheme: themeState.darkTheme,
themeMode: themeState.mode,
routerConfig: AppRouter.config(
authCubit: context.read<AuthCubit>(),
),
),
),
)
// Sayfa bazlı BLoC
BlocProvider(
create: (_) => getIt<SearchBloc>(),
child: const SearchPage(),
)
// BLoC'u parent'tan al (aynı instance)
BlocProvider.value(
value: context.read<CartCubit>(),
child: const CartSummarySheet(),
)
Cubit basit state için yeterlidir; event geçmişini kaydetmek, dışarıdan debug etmek veya birden fazla kaynaktan event geldiğinde BLoC'un yapısı daha avantajlıdır. Her iki yaklaşımda da buildWhen ve listenWhen koşullarını kullanmak gereksiz rebuild'leri ve listener tetiklemelerini önler.