Flutter #30: BLoC — Event, State & BlocBuilder
Halo! Di artikel #29 kita sudah kenal Cubit — versi ringkas dari BLoC yang simpel dan powerful. Tapi begitu logic-nya makin kompleks, Cubit punya kelemahan: tidak ada batasan tindakan. Siapa aja bisa panggil increment() kapan aja — gak ada trace, gak ada history.
Masuklah BLoC penuh dengan Event dan State yang terpisah. Pattern ini bikin flow data jadi: Event masuk → BLoC proses → State keluar. Terstruktur, predictable, dan gampang di-debug.
Let’s go!
1. Cubit vs BLoC: Bedanya Apa?
| Aspek | Cubit | BLoC |
|---|---|---|
| Input | Fungsi biasa (increment()) |
Event (IncrementPressed) |
| State change | emit(state.copyWith(...)) |
emit(Loading → Success) |
| Traceability | Gak ada history | Event bisa di-log, replay, test |
| Complexity | Cocok untuk simple logic | Cocok untuk complex workflow |
Rule of thumb: Kalau logic-nya cuma “kalau X, emit Y” → pakai Cubit. Kalau butuh “kalau event A, proses async, bisa gagal, emit loading → success/error” → pakai BLoC.
2. Arsitektur Event-Driven
Ini flow data di BLoC:
UI ─── dispatch(IncrementPressed()) ───→ BLoC ─── emit(CounterState(count: 1)) ───→ UI
╰── Event ──────────────────────╯ ╰── Proses ──────────────────────╯
- Event — aksi dari user (tombol ditekan, form disubmit, API dipanggil)
- BLoC — terima event, proses logic, emit state baru
- State — representasi condition UI saat ini
- UI — render berdasarkan state
Keuntungan? Setiap perubahan state bisa di-trace ke event tertentu. Debugging jadi jauh lebih gampang!
3. Setup Package
Masih sama seperti Cubit — kita pakai flutter_bloc:
# pubspec.yaml
dependencies:
flutter_bloc: ^8.1.0
equatable: ^2.0.5
freezed_annotation: ^2.4.1 # optional, tapi membantu
dev_dependencies:
build_runner: ^2.4.0
freezed: ^2.4.0 # optional
4. Contoh Pertama: Counter BLoC
Bikin 3 file terpisah — Event, State, dan BLoC:
Event (counter_event.dart)
import 'package:equatable/equatable.dart';
// Semua event turunan dari CounterEvent
abstract class CounterEvent extends Equatable {
const CounterEvent();
@override
List<Object?> get props => [];
}
// Event ketika user pencet tombol tambah
class CounterIncremented extends CounterEvent {
const CounterIncremented();
}
// Event ketika user pencet tombol kurang
class CounterDecremented extends CounterEvent {
const CounterDecremented();
}
// Event ketika user pencet reset
class CounterReset extends CounterEvent {
const CounterReset();
}
State (counter_state.dart)
import 'package:equatable/equatable.dart';
class CounterState extends Equatable {
final int count;
final String status; // 'initial', 'active', 'reset'
const CounterState({
this.count = 0,
this.status = 'initial',
});
CounterState copyWith({int? count, String? status}) {
return CounterState(
count: count ?? this.count,
status: status ?? this.status,
);
}
@override
List<Object?> get props => [count, status];
}
BLoC (counter_bloc.dart)
import 'package:flutter_bloc/flutter_bloc.dart';
import 'counter_event.dart';
import 'counter_state.dart';
class CounterBloc extends Bloc<CounterEvent, CounterState> {
CounterBloc() : super(const CounterState()) {
// Register handler untuk setiap event
on<CounterIncremented>(_onIncrement);
on<CounterDecremented>(_onDecrement);
on<CounterReset>(_onReset);
}
// Handler untuk CounterIncremented
void _onIncrement(
CounterIncremented event,
Emitter<CounterState> emit,
) {
emit(state.copyWith(
count: state.count + 1,
status: 'active',
));
}
// Handler untuk CounterDecremented
void _onDecrement(
CounterDecremented event,
Emitter<CounterState> emit,
) {
final newCount = state.count > 0 ? state.count - 1 : 0;
emit(state.copyWith(
count: newCount,
status: newCount == 0 ? 'initial' : 'active',
));
}
// Handler untuk CounterReset
void _onReset(
CounterReset event,
Emitter<CounterState> emit,
) {
emit(const CounterState(count: 0, status: 'reset'));
}
}
Perhatikan polanya:
on<EventType>(_handler)— daftarkan handler untuk setiap event. Beda sama Cubit yang pakai fungsi biasa.Emitter<CounterState>— paramemitdi BLoC lebih aman, hanya bisa diakses di dalam handler._onIncrement— naming convention:_on+ nama event.
5. BlocProvider — Tanam BLoC ke Tree
Sama seperti Cubit, kita pakai BlocProvider:
void main() {
runApp(
BlocProvider(
create: (_) => CounterBloc(),
child: const MyApp(),
),
);
}
Tapi bedanya, sekarang UI mengirim Event, bukan memanggil fungsi langsung:
// Cubit (sebelumnya)
context.read<CounterCubit>().increment();
// BLoC (sekarang)
context.read<CounterBloc>().add(const CounterIncremented());
Yang dikirim ke BLoC adalah event object, bukan method call. Inilah yang bikin BLoC lebih traceable!
6. BlocBuilder — Render UI dari State
BlocBuilder adalah widget utama untuk render UI berdasarkan state:
class CounterPage extends StatelessWidget {
const CounterPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Counter BLoC')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// BlocBuilder rebuild hanya saat state berubah
BlocBuilder<CounterBloc, CounterState>(
buildWhen: (previous, current) {
// Cuma rebuild kalau count berubah
return previous.count != current.count;
},
builder: (context, state) {
return Column(
children: [
Text(
'${state.count}',
style: const TextStyle(
fontSize: 72,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'Status: ${state.status}',
style: TextStyle(
fontSize: 16,
color: state.status == 'reset'
? Colors.orange
: Colors.grey,
),
),
],
);
},
),
const SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
ElevatedButton(
onPressed: () {
context.read<CounterBloc>().add(
const CounterDecremented(),
);
},
child: const Icon(Icons.remove),
),
const SizedBox(width: 16),
ElevatedButton(
onPressed: () {
context.read<CounterBloc>().add(
const CounterIncremented(),
);
},
child: const Icon(Icons.add),
),
const SizedBox(width: 16),
OutlinedButton(
onPressed: () {
context.read<CounterBloc>().add(
const CounterReset(),
);
},
child: const Text('Reset'),
),
],
),
],
),
),
);
}
}
Yang penting:
BlocBuildercuma rebuild bagian widget yang berubahbuildWhenmemberikan kontrol eksplisit kapan rebuild terjadicontext.read<CounterBloc>().add(...)— kirim event ke BLoC
7. BlocListener — Side Effects
Mau tampilkan SnackBar ketika state tertentu? Pakai BlocListener:
BlocListener<CounterBloc, CounterState>(
listenWhen: (previous, current) {
// Cuma listen kalau status berubah ke 'reset'
return previous.status != current.status &&
current.status == 'reset';
},
listener: (context, state) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Counter sudah di-reset! 🔃'),
backgroundColor: Colors.orange,
),
);
},
child: const CounterBody(), // child tetap rebuild normal
),
BlocListener tidak rebuild child-nya — dia cuma eksekusi side-effect. Berbeda dengan BlocBuilder.
8. Contoh Nyata: Async BLoC dengan Loading/Error
Ini di mana BLoC bersinar — handle async operation dengan state yang terstruktur:
// === State: punya 3 kondisi ===
abstract class UserState extends Equatable {
const UserState();
@override
List<Object?> get props => [];
}
class UserInitial extends UserState {}
class UserLoading extends UserState {}
class UserLoaded extends UserState {
final String name;
final String email;
const UserLoaded({required this.name, required this.email});
@override
List<Object?> get props => [name, email];
}
class UserError extends UserState {
final String message;
const UserError({required this.message});
@override
List<Object?> get props => [message];
}
// === Event ===
class UserFetched extends UserEvent {
final int userId;
const UserFetched({required this.userId});
@override
List<Object?> get props => [userId];
}
// === BLoC ===
class UserBloc extends Bloc<UserEvent, UserState> {
final ApiRepository repository;
UserBloc({required this.repository}) : super(UserInitial()) {
on<UserFetched>(_onUserFetched);
}
Future<void> _onUserFetched(
UserFetched event,
Emitter<UserState> emit,
) async {
// 1. Emit Loading
emit(UserLoading());
try {
// 2. Fetch data dari API
final user = await repository.fetchUser(event.userId);
// 3. Emit Success
emit(UserLoaded(name: user.name, email: user.email));
} catch (e) {
// 4. Emit Error
emit(UserError(message: 'Gagal load user: $e'));
}
}
}
UI-nya jadi super clean:
BlocBuilder<UserBloc, UserState>(
builder: (context, state) {
if (state is UserLoading) {
return const CircularProgressIndicator();
}
if (state is UserError) {
return Text('Error: ${state.message}');
}
if (state is UserLoaded) {
return Text('Halo, ${state.name}!');
}
return const Text('Tekan tombol untuk load user');
},
);
Inilah kekuatan BLoC: UI tinggal handle 4 kondisi — Initial, Loading, Loaded, Error. Gak ada logic yang bocor ke UI!
9. Event Transformer — Debounce & Throttle
BLoC punya fitur keren: transform event stream. Contoh: debounce untuk search input:
class SearchBloc extends Bloc<SearchEvent, SearchState> {
SearchBloc() : super(SearchInitial()) {
// Debounce: tunggu user berhenti ketik 300ms baru proses
on<SearchQueryChanged>(
_onSearchChanged,
transformer: debounce(Duration(milliseconds: 300)),
);
}
Future<void> _onSearchChanged(
SearchQueryChanged event,
Emitter<SearchState> emit,
) async {
if (event.query.isEmpty) {
emit(SearchInitial());
return;
}
emit(SearchLoading());
try {
final results = await apiService.search(event.query);
emit(SearchLoaded(results: results));
} catch (e) {
emit(SearchError(message: 'Search failed'));
}
}
}
// Helper function untuk debounce
EventTransformer<E> debounce<E>(Duration duration) {
return (events, mapper) {
return events.debounceTime(duration).asyncExpand(mapper);
};
}
Tanpa debounce, setiap ketikan trigger API call. Dengan debounce, cuma trigger setelah user berhenti 300ms. Hemat resource!
10. Perbandingan Cubit vs BLoC — Kapan Pakai Apa?
| Skenario | Pilih | Alasan |
|---|---|---|
| Counter, Toggle, Simple state | Cubit | Lebih sedikit boilerplate |
| Form submit + validation | BLoC | Perlu Loading/Success/Error state |
| API call dengan cache | BLoC | Event transformer, complex flow |
| Theme switcher | Cubit | Simpel, cuma emit state |
| Search dengan debounce | BLoC | Transformer built-in |
| Auth flow (login, register, logout) | BLoC | Multiple async steps, error handling |
Prinsipnya: Mulai dari Cubit. Kalau mentok, naik ke BLoC. Jangan over-engineer dari awal!
Kesimpulan
Hari ini kita belajar:
- Event — aksi yang dikirim dari UI ke BLoC
- State — kondisi yang di-emit BLoC ke UI
- BLoC — register handler per event, proses logic, emit state
BlocBuilder— render UI dari stateBlocListener— eksekusi side-effect tanpa rebuild- Async pattern —
Loading → Loaded/Erroryang clean - Event Transformer — debounce & throttle untuk optimasi
Di artikel #31 kita lanjut ke GetX — state management all-in-one yang pakai syntax super ringkas. Beda banget sama BLoC!
Coba sendiri! Buat BLoC untuk fetch data dari jsonplaceholder.typicode.com/users, tampilkan di ListView dengan state Loading/Success/Error. Share hasilnya ke sosial media dan tag @ahsai001 🚀