Skip to main content

State Management dengan BLoC

MStore menggunakan BLoC (Business Logic Component) Pattern untuk state management yang scalable dan testable.

🎯 Mengapa BLoC?

Keuntungan BLoC Pattern

  1. Separation of Concerns - Business logic terpisah dari UI
  2. Testability - Mudah di-unit test tanpa UI
  3. Reusability - BLoC bisa digunakan di multiple screens
  4. Predictability - State changes melalui events yang jelas
  5. Debugging - Easy to track state changes
  6. Platform Agnostic - Bisa digunakan di Flutter, AngularDart, dll

πŸ“¦ Dependencies

dependencies:
  bloc: ^9.0.0
  flutter_bloc: ^9.1.1
  hydrated_bloc: ^10.1.1  # Untuk persist state
  equatable: ^2.0.7       # Untuk value equality

πŸ—οΈ Arsitektur BLoC

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                         UI Layer                         β”‚
β”‚                  (Widgets, Pages)                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           ↓
                    BlocBuilder/
                   BlocListener/
                   BlocConsumer
                           ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                      BLoC Layer                          β”‚
β”‚                (Business Logic)                          β”‚
β”‚                                                          β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”         β”‚
β”‚  β”‚  Event   β”‚ β†’  β”‚   BLoC   β”‚ β†’  β”‚  State   β”‚         β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜         β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                           ↓
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                    Repository Layer                      β”‚
β”‚                  (Data Access)                           β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

πŸ“ Struktur BLoC

1. Events

Events adalah input ke BLoC yang memicu state changes.
// features/product/bloc/product_event.dart
abstract class ProductEvent extends Equatable {
  const ProductEvent();
  
  @override
  List<Object?> get props => [];
}

class LoadProducts extends ProductEvent {
  const LoadProducts();
}

class LoadProductById extends ProductEvent {
  final String productId;
  
  const LoadProductById(this.productId);
  
  @override
  List<Object?> get props => [productId];
}

class CreateProduct extends ProductEvent {
  final Product product;
  
  const CreateProduct(this.product);
  
  @override
  List<Object?> get props => [product];
}

class UpdateProduct extends ProductEvent {
  final String productId;
  final Product product;
  
  const UpdateProduct({
    required this.productId,
    required this.product,
  });
  
  @override
  List<Object?> get props => [productId, product];
}

class DeleteProduct extends ProductEvent {
  final String productId;
  
  const DeleteProduct(this.productId);
  
  @override
  List<Object?> get props => [productId];
}

class SearchProducts extends ProductEvent {
  final String query;
  
  const SearchProducts(this.query);
  
  @override
  List<Object?> get props => [query];
}

2. States

States merepresentasikan kondisi UI pada waktu tertentu.
// features/product/bloc/product_state.dart
abstract class ProductState extends Equatable {
  const ProductState();
  
  @override
  List<Object?> get props => [];
}

class ProductInitial extends ProductState {
  const ProductInitial();
}

class ProductLoading extends ProductState {
  const ProductLoading();
}

class ProductLoaded extends ProductState {
  final List<Product> products;
  
  const ProductLoaded(this.products);
  
  @override
  List<Object?> get props => [products];
}

class ProductDetailLoaded extends ProductState {
  final Product product;
  
  const ProductDetailLoaded(this.product);
  
  @override
  List<Object?> get props => [product];
}

class ProductError extends ProductState {
  final String message;
  
  const ProductError(this.message);
  
  @override
  List<Object?> get props => [message];
}

class ProductOperationSuccess extends ProductState {
  final String message;
  
  const ProductOperationSuccess(this.message);
  
  @override
  List<Object?> get props => [message];
}

3. BLoC Implementation

// features/product/bloc/product_bloc.dart
class ProductBloc extends Bloc<ProductEvent, ProductState> {
  final ProductRepository _productRepository;
  
  ProductBloc({
    required ProductRepository productRepository,
  })  : _productRepository = productRepository,
        super(const ProductInitial()) {
    // Register event handlers
    on<LoadProducts>(_onLoadProducts);
    on<LoadProductById>(_onLoadProductById);
    on<CreateProduct>(_onCreateProduct);
    on<UpdateProduct>(_onUpdateProduct);
    on<DeleteProduct>(_onDeleteProduct);
    on<SearchProducts>(_onSearchProducts);
  }
  
  Future<void> _onLoadProducts(
    LoadProducts event,
    Emitter<ProductState> emit,
  ) async {
    emit(const ProductLoading());
    
    final result = await _productRepository.getProducts();
    
    result.fold(
      (failure) => emit(ProductError(failure.message)),
      (products) => emit(ProductLoaded(products)),
    );
  }
  
  Future<void> _onLoadProductById(
    LoadProductById event,
    Emitter<ProductState> emit,
  ) async {
    emit(const ProductLoading());
    
    final result = await _productRepository.getProductById(event.productId);
    
    result.fold(
      (failure) => emit(ProductError(failure.message)),
      (product) => emit(ProductDetailLoaded(product)),
    );
  }
  
  Future<void> _onCreateProduct(
    CreateProduct event,
    Emitter<ProductState> emit,
  ) async {
    emit(const ProductLoading());
    
    final result = await _productRepository.createProduct(event.product);
    
    result.fold(
      (failure) => emit(ProductError(failure.message)),
      (_) {
        emit(const ProductOperationSuccess('Product created successfully'));
        // Reload products
        add(const LoadProducts());
      },
    );
  }
  
  Future<void> _onUpdateProduct(
    UpdateProduct event,
    Emitter<ProductState> emit,
  ) async {
    emit(const ProductLoading());
    
    final result = await _productRepository.updateProduct(
      event.productId,
      event.product,
    );
    
    result.fold(
      (failure) => emit(ProductError(failure.message)),
      (_) {
        emit(const ProductOperationSuccess('Product updated successfully'));
        add(const LoadProducts());
      },
    );
  }
  
  Future<void> _onDeleteProduct(
    DeleteProduct event,
    Emitter<ProductState> emit,
  ) async {
    emit(const ProductLoading());
    
    final result = await _productRepository.deleteProduct(event.productId);
    
    result.fold(
      (failure) => emit(ProductError(failure.message)),
      (_) {
        emit(const ProductOperationSuccess('Product deleted successfully'));
        add(const LoadProducts());
      },
    );
  }
  
  Future<void> _onSearchProducts(
    SearchProducts event,
    Emitter<ProductState> emit,
  ) async {
    if (event.query.isEmpty) {
      add(const LoadProducts());
      return;
    }
    
    emit(const ProductLoading());
    
    final result = await _productRepository.searchProducts(event.query);
    
    result.fold(
      (failure) => emit(ProductError(failure.message)),
      (products) => emit(ProductLoaded(products)),
    );
  }
}

🎨 UI Integration

BlocProvider

Menyediakan BLoC ke widget tree.
class ProductListPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocProvider(
      create: (context) => getIt<ProductBloc>()..add(const LoadProducts()),
      child: ProductListView(),
    );
  }
}

BlocBuilder

Rebuild widget saat state berubah.
class ProductListView extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<ProductBloc, ProductState>(
      builder: (context, state) {
        if (state is ProductLoading) {
          return const Center(child: CircularProgressIndicator());
        }
        
        if (state is ProductError) {
          return Center(child: Text(state.message));
        }
        
        if (state is ProductLoaded) {
          return ListView.builder(
            itemCount: state.products.length,
            itemBuilder: (context, index) {
              final product = state.products[index];
              return ProductListItem(product: product);
            },
          );
        }
        
        return const SizedBox.shrink();
      },
    );
  }
}

BlocListener

Listen state changes tanpa rebuild.
class ProductFormPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocListener<ProductBloc, ProductState>(
      listener: (context, state) {
        if (state is ProductOperationSuccess) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(state.message)),
          );
          Navigator.pop(context);
        }
        
        if (state is ProductError) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(
              content: Text(state.message),
              backgroundColor: Colors.red,
            ),
          );
        }
      },
      child: ProductForm(),
    );
  }
}

BlocConsumer

Kombinasi BlocBuilder + BlocListener.
class ProductDetailPage extends StatelessWidget {
  final String productId;
  
  const ProductDetailPage({required this.productId});

  @override
  Widget build(BuildContext context) {
    return BlocConsumer<ProductBloc, ProductState>(
      listener: (context, state) {
        if (state is ProductError) {
          ScaffoldMessenger.of(context).showSnackBar(
            SnackBar(content: Text(state.message)),
          );
        }
      },
      builder: (context, state) {
        if (state is ProductLoading) {
          return const Center(child: CircularProgressIndicator());
        }
        
        if (state is ProductDetailLoaded) {
          return ProductDetailView(product: state.product);
        }
        
        return const SizedBox.shrink();
      },
    );
  }
}

πŸ’Ύ Hydrated BLoC (Persistent State)

Untuk persist state across app restarts.
class SettingsBloc extends HydratedBloc<SettingsEvent, SettingsState> {
  SettingsBloc() : super(const SettingsState.initial()) {
    on<UpdateTheme>(_onUpdateTheme);
    on<UpdateLanguage>(_onUpdateLanguage);
  }
  
  @override
  SettingsState? fromJson(Map<String, dynamic> json) {
    try {
      return SettingsState.fromJson(json);
    } catch (_) {
      return null;
    }
  }
  
  @override
  Map<String, dynamic>? toJson(SettingsState state) {
    try {
      return state.toJson();
    } catch (_) {
      return null;
    }
  }
}
Setup di main:
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  final directory = await getApplicationDocumentsDirectory();
  HydratedBloc.storage = await HydratedStorage.build(
    storageDirectory: HydratedStorageDirectory(directory.path),
  );
  
  runApp(MyApp());
}

πŸ” BLoC Observer

Global observer untuk logging dan debugging.
// lib/core/observers/app_bloc_observer.dart
class AppBlocObserver extends BlocObserver {
  const AppBlocObserver();

  @override
  void onCreate(BlocBase bloc) {
    super.onCreate(bloc);
    AppLog.i('bloc.${bloc.runtimeType}', 'created');
  }

  @override
  void onEvent(Bloc bloc, Object? event) {
    super.onEvent(bloc, event);
    AppLog.i('bloc.${bloc.runtimeType}', 'event: $event');
  }

  @override
  void onChange(BlocBase bloc, Change change) {
    super.onChange(bloc, change);
    AppLog.i('bloc.${bloc.runtimeType}', 'state_change', meta: {
      'current': change.currentState.runtimeType.toString(),
      'next': change.nextState.runtimeType.toString(),
    });
  }

  @override
  void onTransition(Bloc bloc, Transition transition) {
    super.onTransition(bloc, transition);
    AppLog.i('bloc.${bloc.runtimeType}', 'transition', meta: {
      'event': transition.event.runtimeType.toString(),
      'current': transition.currentState.runtimeType.toString(),
      'next': transition.nextState.runtimeType.toString(),
    });
  }

  @override
  void onError(BlocBase bloc, Object error, StackTrace stackTrace) {
    super.onError(bloc, error, stackTrace);
    AppLog.e('bloc.${bloc.runtimeType}', 'error', error, stackTrace);
  }

  @override
  void onClose(BlocBase bloc) {
    super.onClose(bloc);
    AppLog.i('bloc.${bloc.runtimeType}', 'closed');
  }
}
Register di main:
void main() {
  Bloc.observer = const AppBlocObserver();
  runApp(MyApp());
}

🧩 Advanced Patterns

Bloc-to-Bloc Communication

class CartBloc extends Bloc<CartEvent, CartState> {
  final ProductBloc _productBloc;
  StreamSubscription? _productSubscription;
  
  CartBloc({required ProductBloc productBloc})
      : _productBloc = productBloc,
        super(const CartInitial()) {
    // Listen to product changes
    _productSubscription = _productBloc.stream.listen((state) {
      if (state is ProductLoaded) {
        add(UpdateAvailableProducts(state.products));
      }
    });
  }
  
  @override
  Future<void> close() {
    _productSubscription?.cancel();
    return super.close();
  }
}

Debouncing Events

class SearchBloc extends Bloc<SearchEvent, SearchState> {
  SearchBloc() : super(const SearchInitial()) {
    on<SearchQueryChanged>(
      _onSearchQueryChanged,
      transformer: debounce(const Duration(milliseconds: 300)),
    );
  }
  
  Future<void> _onSearchQueryChanged(
    SearchQueryChanged event,
    Emitter<SearchState> emit,
  ) async {
    // Search logic
  }
}

// Transformer
EventTransformer<T> debounce<T>(Duration duration) {
  return (events, mapper) => events.debounceTime(duration).flatMap(mapper);
}

Throttling Events

on<ButtonPressed>(
  _onButtonPressed,
  transformer: throttle(const Duration(seconds: 1)),
);

EventTransformer<T> throttle<T>(Duration duration) {
  return (events, mapper) => events.throttleTime(duration).flatMap(mapper);
}

πŸ§ͺ Testing BLoC

Unit Test

void main() {
  group('ProductBloc', () {
    late ProductBloc bloc;
    late MockProductRepository mockRepository;

    setUp(() {
      mockRepository = MockProductRepository();
      bloc = ProductBloc(productRepository: mockRepository);
    });

    tearDown(() {
      bloc.close();
    });

    test('initial state is ProductInitial', () {
      expect(bloc.state, equals(const ProductInitial()));
    });

    blocTest<ProductBloc, ProductState>(
      'emits [ProductLoading, ProductLoaded] when LoadProducts succeeds',
      build: () {
        when(() => mockRepository.getProducts()).thenAnswer(
          (_) async => Right([Product(id: '1', name: 'Test')]),
        );
        return bloc;
      },
      act: (bloc) => bloc.add(const LoadProducts()),
      expect: () => [
        const ProductLoading(),
        isA<ProductLoaded>()
            .having((s) => s.products.length, 'products length', 1),
      ],
    );

    blocTest<ProductBloc, ProductState>(
      'emits [ProductLoading, ProductError] when LoadProducts fails',
      build: () {
        when(() => mockRepository.getProducts()).thenAnswer(
          (_) async => Left(ServerFailure('Server error')),
        );
        return bloc;
      },
      act: (bloc) => bloc.add(const LoadProducts()),
      expect: () => [
        const ProductLoading(),
        const ProductError('Server error'),
      ],
    );
  });
}

πŸ“Š Performance Tips

1. Use Equatable

class ProductState extends Equatable {
  final List<Product> products;
  
  const ProductState(this.products);
  
  @override
  List<Object?> get props => [products];
}

2. Avoid Rebuilding Entire Tree

// ❌ Bad: Rebuilds entire page
BlocBuilder<ProductBloc, ProductState>(
  builder: (context, state) {
    return EntirePage(state: state);
  },
)

// βœ… Good: Only rebuilds specific widget
Column(
  children: [
    StaticHeader(),
    BlocBuilder<ProductBloc, ProductState>(
      builder: (context, state) {
        return ProductList(products: state.products);
      },
    ),
    StaticFooter(),
  ],
)

3. Use buildWhen

BlocBuilder<ProductBloc, ProductState>(
  buildWhen: (previous, current) {
    // Only rebuild when products actually change
    return previous.products != current.products;
  },
  builder: (context, state) {
    return ProductList(products: state.products);
  },
)

πŸ“š Best Practices

  1. Single Responsibility - Satu BLoC untuk satu feature
  2. Immutable States - Gunakan @immutable atau const
  3. Equatable - Implement untuk efficient rebuilds
  4. Error Handling - Selalu handle error states
  5. Loading States - Show loading indicators
  6. Dispose - Close BLoC saat tidak digunakan
  7. Testing - Write unit tests untuk semua BLoCs
  8. Logging - Use BlocObserver untuk debugging

Next Steps


BLoC Pattern memberikan:
  • βœ… Clear separation of concerns
  • βœ… Predictable state management
  • βœ… Easy testing
  • βœ… Scalable architecture