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