This is an adaptation of an article previously published on Medium.
When developement of applications is considered, we can think of every action the app is supposed to perform as an asynchronous event - whether it is fetching data from the outside world, interacting with the underlying system, or handling of user input. Keeping this in mind we can also think of the updates in the UI due to changes in the state as an asynchronous event. This concept is used by one of the state management patterns used in Flutter known as “Business Logic Components” aka BLoC.
Fortunately, Dart, the language in which Flutter apps are written, has built-in support for aynchronous programming, with out-of-the-box support for Futures and Streams. But the most common way to implement this pattern is through the bloc and flutter_bloc packages, which, although takes care of all the boilerplate needed, made the underlying concept of the pattern very difficult to understand for me when I was getting started.
This article is an attempt to explain this pattern in the most simplest way possible in all its boilerplate glory.
The basics ¶
In a typical “reactive” application, the UI can be thought as a function of state. The state, in turn, depends on the model. The data of the model can be either internal or can be something brought in from the outside world.
This is not a one way street though, as UI can also make changes to the model(usually through a controller layer), which causes the state to change and this whole cycle continues.
For updates to the UI to be managed through asynchronous events, we would need two sets of events: one from the UI layer to the controller layer and another to the UI layer with the updated state.
Since the events are asynchronous, they can appear at any time or not at all, and we cannot use them directly. As before, Dart provides us with Futures and Streams to handle asynchronous data. Since the events go in a sequence and can be more than one during the lifecycle of the app, we can use Streams.
Thinking in Streams ¶
A stream can be thought of being a pipe. Data can be pushed to its input known as its sink, and they can be received from its output(also called listening) as its stream(not to be confused with the pipe “stream” itself).
To easily create a stream, this article uses a StreamController, which doesn’t require any know how of streams other than above.
Going back to managing the sets of asynchronous events, we would need two streams. In the context of BLoC, we construct a “BLoC” class, which receives events from the UI through a stream, uses the received data to perform the required business logic with the help of the model, and then sends the result(state) back to the UI through another stream.
Implementing the BLoC ¶
The “events” are typically managed using Enums…
…although they can also be subclasses of a sealed class:
1sealed class EventClass {}
2final class EventOne extends EventClass {}
3final class EventTwo extends EventClass {}
4final class EventThree extends EventClass {}
In a BLoC, we create two streams each typed as per the type of the data passing through. We expose the sink of one for the UI to send and the stream of another for the UI to receive the data back:
1class Bloc {
2 late final Model _model;
3 final StreamController<Event> _eventStreamController = StreamController();
4 final StreamController<String> _stateStreamController = StreamController();
5
6 StreamSink<Event> get eventSink => _eventStreamController.sink;
7 Stream<String> get stateStream => _stateStreamController.stream;
8}
Here the code is considering the state to be of String
type(for instance, we
need to display the formatted checkout amount of a shopping store), hence
_stateStreamController
is a Stream typed to String
.
Once we receive any event from _eventStreamController
, we use the business
logic defined in the BLoC to determine the state to send back to the UI through
the sink of _stateStreamController
.
1class Bloc {
2 late final Model _model;
3 final StreamController<Event> _eventStreamController = StreamController();
4 final StreamController<String> _stateStreamController = StreamController();
5
6 StreamSink<Event> get eventSink => _eventStreamController.sink;
7 Stream<int> get stateStream => _stateStreamController.stream;
8
9 void performLogic(Event event) {
10 switch(event) {
11 case Event.eventOne:
12 onEventOne(_model);
13 case Event.eventTwo:
14 onEventTwo(_model);
15 case Event.eventThree:
16 onEventThree(_model);
17 }
18 _stateStreamController.sink.add(_model.data);
19 }
20}
Now we hook the business logic to the stream of _eventStreamController
, and
put the initial data of Model
to the sink of _stateStreamController
. We
also close all streams in the dispose
method.
1class Bloc {
2 late final Model _model;
3 final StreamController<Event> _eventStreamController = StreamController();
4 final StreamController<String> _stateStreamController = StreamController();
5
6 StreamSink<Event> get eventSink => _eventStreamController.sink;
7 Stream<String> get stateStream => _stateStreamController.stream;
8
9 void performLogic(Event event) {
10 switch(event) {
11 case Event.eventOne:
12 onEventOne(_model);
13 case Event.eventTwo:
14 onEventTwo(_model);
15 case Event.eventThree:
16 onEventThree(_model);
17 }
18 _stateStreamController.sink.add(_model.data);
19 }
20
21 Bloc({required Model model}) {
22 _model = model;
23 _stateStreamController.sink.add(_model.data);
24 _eventStreamController.stream.listen(performLogic);
25 }
26
27 void dispose() {
28 _eventStreamController.close();
29 _stateStreamController.close();
30 }
31}
Hooking the UI ¶
Flutter provides a neat widget StreamBuilder that builds UI depending on
the data received from a stream, so using that in this context is a no brainer.
But how do we access the Bloc
class in the first place?
“Providing” the BLoC ¶
Making data available for widgets that need it can be done by defining a
BlocProvider
class which extends InheritedWidget.
1class BlocProvider extends InheritedWidget {
2 final Bloc bloc;
3 const BlocProvider({
4 super.key,
5 required this.bloc,
6 required super.child,
7 });
8
9 @override
10 bool updateShouldNotify(BlocProvider oldWidget) => true;
11
12 static BlocProvider? of(BuildContext context) =>
13 context.dependOnInheritedWidgetOfExactType<BlocProvider>();
14}
When we add this widget at the top of the widget tree, every widget under it gets access to the BLoC:
1class MyApp extends StatelessWidget {
2 const MyApp({super.key});
3
4 @override
5 Widget build(BuildContext context) {
6 return MaterialApp(
7 title: 'BLoC Demo',
8 home: BlocProvider(
9 bloc: Bloc(model: Model(0)),
10 child: const MyHomePage(title: 'BLoC Demo Home Page'),
11 ),
12 );
13 }
14}
Using the BLoC ¶
Widgets that need the BLoC to send events and receive states need to be called
as StatefulWidget. The lifecycle methods available to those widgets help
in proper initialization and disposal of streams. In the following snippet the
Text
widget is built depending on the data received from the BLoC and the
TextButton
sends events to the BLoC when it is pressed.
1class BlocConsumer extends StatefulWidget {
2 const BlocConsumer({super.key});
3
4 @override
5 State<BlocConsumer> createState() => _BlocConsumerState();
6}
7
8class _BlocConsumerState extends State<BlocConsumer> {
9 late final Bloc? _bloc;
10
11 @override
12 void didChangeDependencies() {
13 _bloc = BlocProvider.of(context)?.bloc;
14 super.didChangeDependencies();
15 }
16
17 @override
18 Widget build(BuildContext context) {
19 if (_bloc == null) return Container();
20 return Column(
21 mainAxisAlignment: MainAxisAlignment.center,
22 children: <Widget>[
23 StreamBuilder(
24 stream: _bloc.stateStream,
25 builder: (context, snapshot) => Text(
26 '${snapshot.data}',
27 style: Theme.of(context).textTheme.headlineMedium,
28 ),
29 ),
30 Container(
31 margin: const EdgeInsets.only(top: 16),
32 child: TextButton(
33 onPressed: () => _bloc.eventSink.add(Event.eventOne),
34 child: const Text('Send Event'),
35 ),
36 ),
37 ],
38 );
39 }
40
41 @override
42 void dispose() {
43 _bloc?.dispose();
44 super.dispose();
45 }
46}
We mainly use two lifecycle methods:
didChangeDependencies
: Here we access the BLoC fromBlocProvider
. Defining here would make the widget itself rebuild whenBlocProvider
is changed.dispose
: Here we call theBloc.dispose
method to dispose the defined streams whenever the state of the widget itself is disposed.
There we have it, BLoC implemented using built in Dart libraries. Although not recommended to be used in production, this could be an useful exercise to understand BLoC implementation wise. There are packages that do the same thing but with less and even lesser code with the ability to minutely track events from the UI event leading to the change of state, from old to the new. The best resource to get started to know more about is at the bloc library’s official website.