DEV Community

Cover image for Applying the BLoC pattern in practice
flutter-clutter
flutter-clutter

Posted on • Originally published at flutterclutter.dev

Applying the BLoC pattern in practice

There are numerous documentations on the web explaining the purpose of the BLoC pattern and how it's applied to minimal examples. This tutorial aims for the migration of an existing app to this state management architecture.

For that, we use the calculator app whose creation was explained in another tutorial. The whole app's state is managed by a StatefulWidget. Let's tweak it to let the entire state be managed by a BLoC which will lead to a separation of the UI from the business logic.

Motivation

Why do we want such a migration in the first place? As long as everything works, we can keep it that way, can't we?

Yes and no. Of course the current implementation works. However, if we were to change anything, we were unable to say with certainty that the functionality would not break.

This problem is nothing new. That's why people invented software testing. But now we come to the core question: is this implementation even testable? What would our unit-tests look like if we wanted to ensure the current implementation?

The answer is: it's not. Our widget has two responsibilities: displaying the UI and defining the business logic. Changing parts of the UI could possibly break the business logic because it's not strictly separated. Same applies the other way around.

Uncle Bob's opinion on that: a responsibility is defined as a reason to change. Every class or module should have exactly one reason to be changed.

Implementation

Implementing the BLoC pattern involves a lot of boilerplate code (Bloc, Event, State and all of its abstract logic). That's why we make it easy for us and use a prefabricated solution: the bloc library. The package contains several classes that prevents us from implementing the pattern ourselves.

Let's add the dependency to the project:

dependencies:
  flutter:
    sdk: flutter
  flutter_bloc: ^6.0.3
  equatable: ^1.2.5
Enter fullscreen mode Exit fullscreen mode

Events

Let's start with the events. That's quite easy because a glimpse at the UI can pretty quickly make us realize which events we need:

Flutter calculator app

There are four types of buttons the user can press resulting in four distinct interactions:

  • Pressing a number button (0-9)
  • Pressing an operator button (+, -, x, /)
  • Pressing the result calculation button (=)
  • Pressing the "clear" button (c)

Flutter calculator BLoC overview

Let's translate these formal requirements into events in the BLoC context:

import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';

abstract class CalculationEvent extends Equatable {
  const CalculationEvent();
}

class NumberPressed extends CalculationEvent {
  final int number;

  const NumberPressed({@required this.number}) : assert(number != null);

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

class OperatorPressed extends CalculationEvent {
  final String operator;

  const OperatorPressed({@required this.operator}) : assert(operator != null);

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

class CalculateResult extends CalculationEvent {
  @override
  List<Object> get props =>  [];
}

class ClearCalculation extends CalculationEvent {
  @override
  List<Object> get props =>  [];
}

Enter fullscreen mode Exit fullscreen mode

The only events that require a parameter are NumberPressed and OperatorPressed (because we need to distinguish which one was taken). The two other events don't have any properties.

States

The states are fairly easy. That's because our UI does not care about what exactly happened. It only cares about the situation when the whole calculation (consisting of two operands, an operator and a result) changes. That's why we only need one actual state saying that the calculation has changed. Additionally we need one initial state. Since we don't have any asynchronous operations, we don't need a "loading" state either.

import 'package:equatable/equatable.dart';
import 'package:meta/meta.dart';

import '../calculation_model.dart';

abstract class CalculationState extends Equatable {
  final CalculationModel calculationModel;

  const CalculationState({@required this.calculationModel}) : assert(calculationModel != null);

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

class CalculationInitial extends CalculationState {
  CalculationInitial() : super(calculationModel: CalculationModel());
}

class CalculationChanged extends CalculationState {
  final CalculationModel calculationModel;

  const CalculationChanged({@required this.calculationModel})
      : assert(calculationModel != null),
        super(calculationModel: calculationModel);

  @override
  List<Object> get props => [calculationModel];
}
Enter fullscreen mode Exit fullscreen mode

Instead of duplicating every property in both of the states, we use a separate class called CalculationModel. This way, if we change something (e.g. displaying the last result as well), we only need to change one model. This is what the model looks like:

import 'package:equatable/equatable.dart';

class CalculationModel extends Equatable {
  CalculationModel({
    this.firstOperand,
    this.operator,
    this.secondOperand,
    this.result,
  });

  final int firstOperand;
  final String operator;
  final int secondOperand;
  final int result;

  @override
  String toString() {
    return "$firstOperand$operator$secondOperand=$result";
  }

  @override
  List<Object> get props => [firstOperand, operator, secondOperand, result];
}
Enter fullscreen mode Exit fullscreen mode

It's important to let this model extend Equatable. Same goes for the states. This is because we need the UI to change only when something has actually changed. We need to define how Dart decides whether there was a change. This is done by defining props.

BLoC

class CalculationBloc extends Bloc<CalculationEvent, CalculationState> {
  CalculationBloc() : super(CalculationInitial());

  @override
  Stream<CalculationState> mapEventToState(
    CalculationEvent event,
  ) async* {
    if (event is ClearCalculation) {
      yield CalculationInitial();
    }
  }
}

Enter fullscreen mode Exit fullscreen mode

There is only one method we need to override: mapEventToState(). This method is responsible for handling incoming events and emitting the new state. We start with the simplest one: ClearCalculation. This makes it emit the initial state again (CalculationInitial).

Next one: OperatorPressed event.

  Future<CalculationState> _mapOperatorPressedToState(
      OperatorPressed event,
      ) async {
    List<String> allowedOperators = ['+', '-', '*', '/'];

    if (!allowedOperators.contains(event.operator)) {
      return state;
    }

    CalculationModel model = state.calculationModel;

    return CalculationChanged(
      calculationModel: CalculationModel(
        firstOperand: model.firstOperand == null ? 0 : model.firstOperand,
        operator: event.operator,
        secondOperand: model.secondOperand,
        result: model.result
      )
    );
  }
Enter fullscreen mode Exit fullscreen mode

We notice something: it's crucial that we use a new instance of the model. If we work with the reference, the bloc library will have problems checking the equality and thus might fail to let the UI know about the update. However, we need to let the new instance have the old values so that we don't lose state when we emit a new CalculationState.

This seems like a lot of boilerplate code if we have a method that handles several cases with several states as outcome. That's why we enhance the CalculationModel by a method called copyWith():

  CalculationModel copyWith({
      int Function() firstOperand,
      String Function() operator,
      int Function() secondOperand,
      int Function() result
    })
  {
    return CalculationModel(
      firstOperand: firstOperand?.call() ?? this.firstOperand,
      operator: operator?.call() ?? this.operator,
      secondOperand: secondOperand?.call() ?? this.secondOperand,
      result: result?.call() ?? this.result,
    );
  }
Enter fullscreen mode Exit fullscreen mode

You might ask yourself why we expect functions and not the date types itself. It's because otherwise null values are treated exactly as not given parameters. I will explain in another article in more detail.

Now we can write the very same method _mapOperatorPressedToState like this:

  Future<CalculationState> _mapOperatorPressedToState(
      OperatorPressed event,
      ) async {
    List<String> allowedOperators = ['+', '-', '*', '/'];

    if (!allowedOperators.contains(event.operator)) {
      return state;
    }

    CalculationModel model = state.calculationModel;

    CalculationModel newModel = state.calculationModel.copyWith(
      firstOperand: () => model.firstOperand == null ? 0 : model.firstOperand,
      operator: () => event.operator
    );

    return CalculationChanged(calculationModel: newModel);
  }
Enter fullscreen mode Exit fullscreen mode

Let's continue with the handling of the CalculateResult event:

  Future<CalculationState> _mapCalculateResultToState(
      CalculateResult event,
    ) async {
    CalculationModel model = state.calculationModel;

    if (model.operator == null || model.secondOperand == null) {
      return state;
    }

    int result = 0;

    switch (model.operator) {
      case '+':
        result = model.firstOperand + model.secondOperand;
        break;
      case '-':
        result = model.firstOperand - model.secondOperand;
        break;
      case '*':
        result = model.firstOperand * model.secondOperand;
        break;
      case '/':
        if (model.secondOperand == 0) {
          CalculationModel resultModel = CalculationInitial().calculationModel.copyWith(
            firstOperand: () => 0
          );

          return CalculationChanged(calculationModel: resultModel);
        }
        result = model.firstOperand ~/ model.secondOperand;
        break;
    }
Enter fullscreen mode Exit fullscreen mode

We just apply the logic, we had before the transformation. Notable here is that a division by zero results in "0" as the first operand for the next calculation.

Now, the biggest event is NumberPressed because depending on the state of calculation we're currently in, pressing a number can have different effects. That's why the handling is a little bit more complex here:

  Future<CalculationState> _mapNumberPressedToState(
    NumberPressed event,
  ) async {
    CalculationModel model = state.calculationModel;

    if (model.result != null) {
      CalculationModel newModel = model.copyWith(
        firstOperand: () => event.number,
        result: () => null
      );

      return CalculationChanged(calculationModel: newModel);
    }

    if (model.firstOperand == null) {
      CalculationModel newModel = model.copyWith(
        firstOperand: () => event.number
      );

      return CalculationChanged(calculationModel: newModel);
    }

    if (model.operator == null) {
      CalculationModel newModel = model.copyWith(
        firstOperand: () => int.parse('${model.firstOperand}${event.number}')
      );

      return CalculationChanged(calculationModel: newModel);
    }

    if (model.secondOperand == null) {
      CalculationModel newModel = model.copyWith(
        secondOperand: () => event.number
      );

      return CalculationChanged(calculationModel: newModel);
    }

    return CalculationChanged(
      calculationModel: model.copyWith(
        secondOperand: () =>  int.parse('${model.secondOperand}${event.number}')
      )
    );
  }
Enter fullscreen mode Exit fullscreen mode

But again, we're just copying off the former behavior from the version of the app without BLoC pattern.

Last thing to do is letting the widget emit the events and react on the emitted states of the BLoC.

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter basic calculator',
      home: Scaffold(
        body: BlocProvider(
          create: (context) {
            return CalculationBloc();
          },
          child: Calculation(),
        ),
      ),
    );
  }
Enter fullscreen mode Exit fullscreen mode

First off, in the main.dart, we need to add a BlocProvider. This is necessary to enable our widget to communicate with the BLoC.

Now, instead of calling setState whenever we recognize an interaction with the UI, we emit an event:

  numberPressed(int number) {
    context.bloc<CalculationBloc>().add(NumberPressed(number: number));
  }

  operatorPressed(String operator) {
    context.bloc<CalculationBloc>().add(OperatorPressed(operator: operator));
  }

  calculateResult() {
    context.bloc<CalculationBloc>().add(CalculateResult());
  }

  clear() {
    context.bloc<CalculationBloc>().add(ClearCalculation());
  }
Enter fullscreen mode Exit fullscreen mode

Now, if we want the UI to display the result accordingly, we need to wrap the ResultDisplay with a BlocBuilder this gives us the ability to react on an emitted state.

BlocBuilder<CalculationBloc, CalculationState>(
  builder: (context, CalculationState state) {
    return ResultDisplay(
      text: _getDisplayText(state.calculationModel),
    );
  },
),
...
String _getDisplayText(CalculationModel model) {
  if (model.result != null) {
    return '${model.result}';
  }

  if (model.secondOperand != null) {
    return '${model.firstOperand}${model.operator}${model.secondOperand}';
  }

  if (model.operator != null) {
    return '${model.firstOperand}${model.operator}';
  }

  if (model.firstOperand != null) {
    return '${model.firstOperand}';
  }

  return "${model.result ?? 0}";
}
Enter fullscreen mode Exit fullscreen mode

Final words

The BLoC pattern is fantastic way of separating UI concerns from business logic concerns. Also, it enables the developer to manage state leaving the widget only as a reactive presentation layer. It might be a little bit confusing at first, but once you get the fundamental thoughts, it is not so hard to apply this pattern. I hope this little example cleared it up a little bit.

If you want the full source, there is the repository before (current master) and after the BLoC PR:

GET FULL CODE WITH BLOC

GET FULL CODE WITHOUT BLOC

Oldest comments (0)