Flutter Bloc + RxDart: A Complete guide to Building Reactive Apps with Flutter 🚀

Flutter Bloc + RxDart: A Complete guide to Building Reactive Apps with Flutter 🚀


flutterdartblocrxdarttutorials

Have you ever wondered how to use streams and observables to manage your app’s state and data? If so, you’re in the right place!

In this post, I’ll show you how to use flutter bloc and reactive repositories to create a fast and responsive app that can handle complex data flows and UI updates.

Reactive repositories are a way of implementing data sources that can emit data changes as streams. To better understand this concept, let’s code an example:

We will build a simple app that has a power switch button that can perform the following actions when tapped:

  • Update the power status with a switch widget.
  • Fetch some data from a fake API.
  • Notify the power status to the users and show a snackbar.

I’ll be using the VeryGood Layered Architecture, which is a scalable and testable way of organizing your code into different layers: presentation, business logic, and data.

Our goal is to have a good separation of concerns between layers and to avoid bad Bloc to Bloc communication by using a reactive repository. Here is a preview of what the app looks like:

iPhone emulator showing the power switch app

Let’s get started! 👨‍💻

To begin, add the necessary dependencies to the pubspec.yaml file:

bloc: ^8.1.2 # A state management library that implements the BLoC design pattern
flutter_bloc: ^8.1.3 # A Flutter package that provides widgets and utilities for working with blocs
rxdart: ^0.27.7 # A reactive programming library that extends the native Dart streams
equatable: ^2.0.5 # A library that simplifies value-based equality for immutable models
fpdart: ^1.1.0 # A library that provides functional programming utilities for Dart

Creating the PowerData Model

We’ll create a PowerData model that represents the data we want to fetch from the API and display on the UI. It has three fields: voltage, current, and lastUpdated.

@immutable
class PowerData extends Equatable {
  const PowerData({
    required this.voltage,
    required this.current,
    required this.lastUpdated,
  });

  final double voltage;
  final double current;
  final DateTime lastUpdated;

  @override
  List<Object?> get props => [voltage, current, lastUpdated];

  PowerData copyWith({
    double? voltage,
    double? current,
    DateTime? lastUpdated,
  }) {
    return PowerData(
      voltage: voltage ?? this.voltage,
      current: current ?? this.current,
      lastUpdated: lastUpdated ?? this.lastUpdated,
    );
  }
}

The use of Equatable ensures value-based equality for immutable models. This means that two instances of the same model with the same field values will be considered equal, regardless of their memory addresses. This is very useful for blocs too, as they rely on equality comparison to determine whether the state has changed or not.

Creating the PowerService

Next, we need to define the PowerSwitchService, which is an abstract interface that defines the methods for interacting with the API. It has two methods: getPowerData and notifyPowerChange:

abstract interface class PowerService {
  Future<PowerData> getPowerData();

  Future<void> notifyPowerChange(bool power);
}

For demonstration purposes, we’ll mock a fake API call within the PowerServiceImpl class, which implements the PowerService interface. We’ll use the Future.delayed constructor to simulate a network delay of 2 seconds, and then return some hard-coded values for the PowerData and the void.

final class PowerServiceImpl implements PowerService {
  @override
  Future<PowerData> getPowerData() async {
    // Simulate a network delay of 2 seconds
    await Future.delayed(const Duration(seconds: 2));
    // Return some hard-coded values for the PowerData
    return PowerData(
      voltage: 220,
      current: 10,
      lastUpdated: DateTime.now(),
    );
  }

  @override
  Future<void> notifyPowerChange(bool power) async {
    // Simulate a network delay of 2 seconds
    await Future.delayed(const Duration(seconds: 2));
    // Do nothing
  }
}

Creating the PowerSwitchRepository

Moving on to the next layer, we need to create the PowerSwitchRepository, which is responsible for managing the data flow between the service and the bloc. It has three methods: powerStream, power, and toggle.

abstract interface class PowerRepository {
  Stream<bool> get powerStream;

  bool get power;

  void toggle();

  Future<Either<Failure, PowerData>> getPowerData();

  Future<Either<Failure, Unit>> notifyPowerChange(bool power);
}

We then create a PowerRepositoryImpl class, which implements the PowerRepository interface and injects the PowerService that interacts with the API.

final class PowerRepositoryImpl implements PowerRepository {
  // Dependency injection...
  PowerRepositoryImpl({required PowerService service}) : _service = service;

  final PowerService _service;
  // We use the BehaviorSubject as a StreamController for the power switch status
  // and initializate it in false.
  final BehaviorSubject<bool> _powerSubject = BehaviorSubject<bool>.seeded(false);

  // Access to the power switch stream...
  @override
  Stream<bool> get powerStream => _powerSubject.stream;

  // Get the last cached value from the BehaviorSubject...
  @override
  bool get power => _powerSubject.valueOrNull ?? false;

// ...
}

This repository becomes reactive thanks to the use of a BehaviorSubject to manage the power switch status. A BehaviorSubject is a special type of stream that has two features:

  • It can emit the last value that was added to the stream, even if there are no listeners. This is useful for caching the data and accessing it later.
  • It can emit an initial value when the stream is created, before any values are added. This is useful for setting a default state for the data.

We use the BehaviorSubject<bool>.seeded(false) constructor to create a BehaviorSubject that emits a bool value and has an initial value of false. This means that the power status is initially off, and we can access it using the valueOrNull getter.

We also implement the toggle method, which simply negates the current power status and adds it to the stream using the add method. This will trigger the stream to emit the new value to any listeners.

  @override
  void toggle() {
    _powerSubject.add(!power);
  }

Here is the full implementation of the PowerRepositoryImpl class, which also includes the methods for fetching the power data and notifying the power change using the service:

final class PowerRepositoryImpl implements PowerRepository {
  // Constructor that takes the service as a required parameter
  PowerRepositoryImpl({required PowerService service}) : _service = service;

  // The service instance
  final PowerService _service;
  // The BehaviorSubject instance
  final BehaviorSubject<bool> _powerSubject =
      BehaviorSubject<bool>.seeded(false);

  // Access to the power switch stream...
  Stream<bool> get powerStream => _powerSubject.stream;

  // Get the last cached value from the BehaviorSubject...
  bool get power => _powerSubject.valueOrNull ?? false;

  // Fetch the power data from the service and return an Either type...
  Future<Either<Failure, PowerData>> getPowerData() async {
    try {
      // Call the service method and await for the result
      final data = await _service.getPowerData();
      // Return a Right type with the data
      return Right(data);
    } catch (e) {
      // Return a Left type with the failure
      return Left(Failure(message: e.toString()));
    }
  }

  // Toggle the power status and add it to the stream...
  void toggle() {
    _powerSubject.add(!power);
  }

  // Notify the power change to the service and return an Either type...
  Future<Either<Failure, Unit>> notifyPowerChange(bool power) async {
    try {
      // Call the service method and await for the completion
      await _service.notifyPowerChange(power);
      // Return a Right type with the unit value
      return const Right(unit);
    } catch (e) {
      // Return a Left type with the failure
      return Left(Failure(message: e.toString()));
    }
  }
}

We use the Either type from the fpdart library to handle errors and failures in a functional way. The Either type can have two possible values: a Left or a Right. By convention, the Left value represents an error or a failure, and the Right value represents a success or a result. This way, we can easily handle different scenarios based on the type of the value, without throwing exceptions or using null values.

We also use the Unit type from the same library to represent a void value. The Unit type is a singleton class that has only one instance: unit. We use it as a return value for some methods that do not need to return anything, but still need to indicate a success or a failure. This way, we can avoid using null values or void futures, and still use the Either type consistently.

If you want to know more about using Unit instead of void, go checkout this medium post explaining the the curious case of void in Dart.

Creating the Blocs

Moving on to the next layer, we need to create our main blocs to perform the actions we want. We will use the flutter_bloc package, which provides widgets and utilities for working with blocs in Flutter. We will create three blocs:

  • PowerSwitchBloc to manage the power switch. It has two events: PowerSwitchStarted and ToggleSwitch. It has one state: PowerSwitchState, which contains a bool value that indicates the power status.
  • PowerDataBloc to manage the data fetching when the power turns on. It has three events: PowerDataStarted, GetPowerData, and _WatchPowerData. It has two states: PowerDataInitial and PowerDataLoaded, which contain an Either<Failure, PowerData> value that indicates the result of the data fetching.

The important thing to consider is to watch the repository power stream on all blocs. This way, we can ensure that the blocs are always in sync with the data source and react to any changes accordingly. We will use the emit.forEach and emit.onEach methods from the bloc package to achieve this. These methods allow us to listen to a stream and emit states based on the data or errors that the stream emits. The difference between them is that emit.forEach requires us to return a valid state for each data or error, while emit.onEach does not.

We will use emit.forEach when we need to update the state with the stream data, and emit.onEach when we need to perform some side effects based on the stream data.

Let’s see how we implement each bloc in detail.

PowerSwitchBloc

class PowerSwitchBloc extends Bloc<PowerSwitchEvent, PowerSwitchState> {
   // Constructor that takes the repository as a required parameter
  PowerSwitchBloc({required PowerRepository repository})
      : _repository = repository,
        super(const PowerSwitchState(false)) {
    // The event handler
    on<PowerSwitchEvent>((event, emit) async {
      return switch (event) {
        // If the event is PowerSwitchStarted, add the _WatchPower event
        PowerSwitchStarted() => add(const _WatchPower()),
        // If the event is ToggleSwitch, call the toggle method from the repositor
        ToggleSwitch() => _repository.toggle(),
        // If the event is _WatchPower, use the emit.forEach method to listen to the power stream and emit states based on the data
        _WatchPower() => await emit.forEach(_repository.powerStream,
            onData: (power) => PowerSwitchState(power))
      };
    });
  }
  // The repository instance
  final PowerRepository _repository;
}

// The events for the PowerSwitchBloc
sealed class PowerSwitchEvent extends Equatable {
  const PowerSwitchEvent();

  @override
  List<Object> get props => [];
}

final class PowerSwitchStarted extends PowerSwitchEvent {
  const PowerSwitchStarted();
}

final class ToggleSwitch extends PowerSwitchEvent {
  const ToggleSwitch();
}

final class _WatchPower extends PowerSwitchEvent {
  const _WatchPower();
}

// The state for the PowerSwitchBloc
class PowerSwitchState extends Equatable {
  const PowerSwitchState(this.power);

  final bool power;

  @override
  List<Object> get props => [power];
}

We use the emit.forEach method to listen to the power stream and emit states based on the data. The onData parameter is a function that takes the stream data and returns a state. In this case, we simply return a PowerSwitchState with the power value. The emit.forEach method will also handle any errors that the stream emits and emit a BlocUnhandledErrorException state. It will also cancel the stream subscription when the bloc is closed or a new event is added.

We also use sealed classes to represent the events and states of your blocs, and then use switch statements to handle them without worrying about missing cases or default to use the new and powerful switch statements on the UI.

PowerDataBloc

The PowerDataBloc is responsible for managing the data fetching when the power turns on. It has three events: PowerDataStarted, GetPowerData, and _WatchPowerData. The former is triggered when the bloc is initialized and adds the _WatchPowerData event. The latter is triggered when the power stream emits a true value and calls the getPowerData method from the repository. The last one is a private event that listens to the power stream and adds the GetPowerData event when the power is on.

class PowerDataBloc extends Bloc<PowerDataEvent, PowerDataState> {
  // Constructor that takes the repository as a required parameter
  PowerDataBloc({required PowerRepository repository})
      : _repository = repository,
        super(PowerDataLoading()) {
    on<PowerDataEvent>((event, emit) async {
      // The event handler
      return switch (event) {
        // If the event is PowerDataStarted, add the _WatchPowerData event
        PowerDataStarted() => add(const _WatchPowerData()),
        // If the event is PowerDataStarted, add the _WatchPowerData event
        GetPowerData() => _onGetPowerData(event, emit),
        // If the event is _WatchPowerData, use the emit.onEach method to listen to the power stream and add the GetPowerData event when the power is on
        _WatchPowerData() => await emit.onEach(
            _repository.powerStream,
            onData: (power) {
              // If the power is off, do nothing
              if (!power) return;
              // If the power is off, do nothing
              add(const GetPowerData());
            },
          )
      };
    });
  }
  // The repository instance
  final PowerRepository _repository;

  // A helper method that handles the GetPowerData event
  FutureOr<void> _onGetPowerData(
    GetPowerData event,
    Emitter<PowerDataState> emit,
  ) async {
    emit(PowerDataLoading());
    // Call the getPowerData method from the repository and await for the result
    final result = await _repository.getPowerData();

    // Use the result.fold method to handle the Either type
    either.fold(
    // If the result is a Left type, return a PowerDataInitial state with the failure message
      (failure) => emit(PowerDataError(failure: failure)),
       // If the result is a Right type, return a PowerDataLoaded state with the data
      (data) => emit(PowerDataLoaded(data)),
    );
  }
}

// The events for the PowerDataBloc
sealed class PowerDataEvent extends Equatable {
  const PowerDataEvent();

  @override
  List<Object> get props => [];
}

final class PowerDataStarted extends PowerDataEvent {
  const PowerDataStarted();
}

final class GetPowerData extends PowerDataEvent {
  const GetPowerData();
}

final class _WatchPowerData extends PowerDataEvent {
  const _WatchPowerData();
}

// The states for the PowerDataBloc
sealed class PowerDataState extends Equatable {
  const PowerDataState();

  @override
  List<Object> get props => [];
}

final class PowerDataLoading extends PowerDataState {}

final class PowerDataLoaded extends PowerDataState {
  const PowerDataLoaded(this.powerData);

  final PowerData powerData;

  @override
  List<Object> get props => [powerData];
}

final class PowerDataError extends PowerDataState {
  const PowerDataError({required this.failure});

  final Failure failure;

  @override
  List<Object> get props => [failure];
}

We use the emit.onEach method to listen to the power stream and perform some side effects based on the data. The onData parameter is a function that takes the stream data and does something with it. In this case, we check if the power is on, and if so, we add the GetPowerData event to fetch the data. The emit.onEach method does not require us to return a state for each data or error, but it will still emit a BlocUnhandledErrorException state if the stream emits an error. It will also cancel the stream subscription when the bloc is closed or a new event is added.

PowerNotifierBloc

The PowerNotifierBloc is responsible for sending the notifications to the user and displaying a snackbar. It has three events: PowerNotifierStarted, NotifyPowerChange, and _WatchPower. The former is triggered when the bloc is initialized and adds the _WatchPower event. The latter is triggered when the power stream emits a value and calls the notifyPowerChange method from the repository. The last one is a private event that listens to the power stream and adds the NotifyPowerChange event with the power value.

class PowerNotifierBloc extends Bloc<PowerNotifierEvent, PowerNotifierState> {
  // Constructor that takes the repository as a required parameter
  PowerNotifierBloc({required PowerRepository repository})
      : _repository = repository,
        super(PowerNotifierLoading()) {
    // The event handler
    on<PowerNotifierEvent>((event, emit) async {
      return switch (event) {
        // If the event is PowerNotifierStarted, add the _WatchPower event
        PowerNotifierStarted() => add(const _WatchPower()),
        // If the event is NotifyPowerChange, call the _onNotifyPowerChange method
        NotifyPowerChange() => _onNotifiyPowerChange(event, emit),
        // If the event is _WatchPower, use the emit.onEach method to listen to the power stream and add the NotifyPowerChange event with the power value
        _WatchPower() => await emit.onEach(_repository.powerStream,
            onData: (power) => add(NotifyPowerChange(power: power)))
      };
    });
  }

  // The repository instance
  final PowerRepository _repository;

  // A helper method that handles the NotifyPowerChange event
  FutureOr<void> _onNotifiyPowerChange(
    NotifyPowerChange event,
    Emitter<PowerNotifierState> emit,
  ) async {
    emit(PowerNotifierLoading());

    // Call the notifyPowerChange method from the repository and await for the result
    final result = await _repository.notifyPowerChange(event.power);

    result.fold(
      // If the result is a Left type, return a PowerNotifierInitial state with the failure message
      (failure) => emit(PowerNotifierError(failure: failure)),
      // If the result is a Right type, return a PowerNotifierLoaded state with the power value
      (_) => emit(PowerNotifierSuccess()),
    );
  }
}

// The events for the PowerNotifierBloc
sealed class PowerNotifierEvent extends Equatable {
  const PowerNotifierEvent();

  @override
  List<Object> get props => [];
}

final class PowerNotifierStarted extends PowerNotifierEvent {
  const PowerNotifierStarted();
}

final class NotifyPowerChange extends PowerNotifierEvent {
  const NotifyPowerChange({required this.power});

  final bool power;

  @override
  List<Object> get props => [power];
}

final class _WatchPower extends PowerNotifierEvent {
  const _WatchPower();
}

// The states for the PowerNotifierBloc
sealed class PowerNotifierState extends Equatable {
  const PowerNotifierState();

  @override
  List<Object> get props => [];
}

final class PowerNotifierLoading extends PowerNotifierState {}

final class PowerNotifierSuccess extends PowerNotifierState {}

final class PowerNotifierError extends PowerNotifierState {
  const PowerNotifierError({required this.failure});

  final Failure failure;

  @override
  List<Object> get props => [failure];
}

We use the emit.onEach method to listen to the power stream and perform some side effects based on the data. The onData parameter is a function that takes the stream data and does something with it. In this case, we add the NotifyPowerChange event with the power value to notify the power change to the service and display a snackbar.

Creating the Views and Widgets

For the presentation layer, we need to create two widgets:

  • PowerSwitch: This widget widget is responsible for updating the power status with a simple switch toggle.
  • LastPowerDataUpdate: This widget is dedicated to showcasing power-related data fetched from the API.

Setting up BLoCS

Within the MyApp widget, we configure the necessary Blocs using MultiBlocProvider:

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // Set up repository using MultiBlocProvider
    return MultiRepositoryProvider(
      providers: [
        RepositoryProvider<PowerService>(
          create: (context) => PowerServiceImpl(),
        ),
        RepositoryProvider<PowerRepository>(
          create: (context) => PowerRepositoryImpl(
            service: context.read<PowerService>(),
          ),
        ),
      ],
      // Set up Blocs using MultiBlocProvider
      child: MultiBlocProvider(
        providers: [
          BlocProvider(
            create: (context) => PowerDataBloc(
              repository: context.read<PowerRepository>(),
            // We're initializing the bloc here...
            )..add(const PowerDataStarted()),
          ),
         // Include other Blocs (e.g., PowerSwitchBloc, PowerNotifierBloc) similarly.
        ],
        child: MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
          ),
          home: const PowerView(), // Designate PowerView as the home page.
        ),
      ),
    );
  }
}

Creating the widgets

We create a simple switch button that consumes the PowerSwitchBloc and a simple widget that consumes the PowerDataBloc and shows the last power data fetched from the API:

class PowerSwitch extends StatelessWidget {
  const PowerSwitch({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<PowerSwitchBloc, PowerSwitchState>(
      builder: (context, state) {
        return Switch(
          value: state.power,
          onChanged: (value) {
            // We toggle the power switch; this action will notify all the other blocs
            // via the repository.
            context.read<PowerSwitchBloc>().add(const ToggleSwitch());
          },
        );
      },
    );
  }
}

class LastPowerDataUpdate extends StatelessWidget {
  const LastPowerDataUpdate({super.key});

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<PowerDataBloc, PowerDataState>(
      builder: (context, state) {
        return switch (state) {
          PowerDataLoading() =>
            const Center(child: CircularProgressIndicator()),
          PowerDataLoaded(:final powerData) =>
            Center(child: Text(powerData.toString())),
          PowerDataError(:final failure) =>
            Center(child: Text(failure.message)),
        };
      },
    );
  }
}

As you can see, I’m using the new switch statement to handle the diferent bloc states without worrying about missing cases.

We have successfully created the power switch and data display widgets, which can consume the blocs and react to the state changes.

Showing the notifier status with a SnackBar

For this action, we need to listen to the PowerNotifierBloc and show a snackbar with the appropriate message to the user. To do this, we will use the BlocListener widget, which can perform side effects based on the state changes of a bloc.

class PowerView extends StatelessWidget {
  const PowerView({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Power'),
      ),
      body: BlocListener<PowerNotifierBloc, PowerNotifierState>(
        listener: (context, state) {
          // We listen to the PowerNotifierState and show the corresponding message to the user.
          switch (state) {
            case PowerNotifierSuccess():
              ScaffoldMessenger.of(context)
                ..hideCurrentSnackBar()
                ..showSnackBar(
                  const SnackBar(content: Text('Power change notified')),
                );
            case PowerNotifierError():
              ScaffoldMessenger.of(context)
                ..hideCurrentSnackBar()
                ..showSnackBar(
                  const SnackBar(
                      content: Text('Failed to notify power change')),
                );
            default:
              break;
          }
        },
        child: const Center(
          child: Column(
            mainAxisSize: MainAxisSize.min,
            children: [
              PowerSwitch(),
              SizedBox(height: 16),
              LastPowerDataUpdate(),
            ],
          ),
        ),
      ),
    );
  }
}

Summary

In this comprehensive guide, we explored the powerful combination of flutter_bloc and rxdart to create highly responsive and reactive apps.

One of the challenges in building robust Flutter applications lies in ensuring effective communication between different blocs without creating tight coupling or dependencies. The use of a reactive repository as a bridge significantly enhances the decoupling and facilitates seamless communication between blocs.

Key Takeaways:

  1. Reactive Repositories for Streamlined Data Management: We implemented reactive repositories that utilize BehaviorSubject from rxdart to efficiently manage data flow between different layers of the app. This approach ensured synchronization with data sources and facilitated immediate reactions to changes.

  2. Bloc-based State Management: Using flutter_bloc, we structured our app’s logic into separate blocs, each responsible for specific functionalities. Sealed classes and switch statements empowered us to handle various events and states with precision, ensuring robust and reliable state management.

  3. Presentation Layer with Reactive UI Components: The presentation layer showcased the power of reactive UI components. Widgets such as PowerSwitch and LastPowerDataUpdate efficiently consumed bloc states, updating the UI dynamically based on the latest data received.

By mastering reactive repositories using flutter_bloc and rxdart, your app now boasts a robust architecture, efficient data handling, and a responsive user interface, setting the stage for creating even more complex and feature-rich applications.

Remember, the key to mastering these tools lies in continual practice and exploration. Experiment with various patterns, dive deeper into the documentation, and embrace the flexibility and power these libraries offer.

Now that you’ve built your first fully reactive app, keep exploring and innovating in the world of Flutter! 🚀✨

Explore the complete source code on GitHub for a deeper understanding of the concepts discussed.

© 2024 Lorenzo Sala

Made with ❤️ and ☕