Effective Bloc-to-Bloc Communication Strategies in the Domain Layer
Optimizing Bloc-to-Bloc Communication with rxdart's reactive programming features
Introduction
Numerous guides, including those from the bloclibrary, address the intricacies of bloc-to-bloc communication and over there, you can find this warning:
Generally, sibling dependencies between two entities in the same architectural layer should be avoided at all costs, as it creates tight-coupling which is hard to maintain. Since blocs reside in the business logic architectural layer, no bloc should know about any other bloc.
So blocs should be connected either through the presentation in the application layer or the domain layer.
Connecting Blocs through Presentation
class MyWidget extends StatelessWidget {
const MyWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocListener<FirstBloc, FirstState>(
listener: (context, state) {
// When the first bloc's state changes, this will be called.
//
// Now we can add an event to the second bloc without it having
// to know about the first bloc.
BlocProvider.of<SecondBloc>(context).add(SecondEvent());
},
child: TextButton(
child: const Text('Hello'),
onPressed: () {
BlocProvider.of<FirstBloc>(context).add(FirstEvent());
},
),
);
}
}
The code above encourages loose coupling, but there's a potential issue if MyWidget
is not mounted when FirstBloc
's state changes. In such cases, the BlocListener
may not be triggered. To address this, you might consider converting MyWidget
to a StatefulWidget
and handling events in the didChangeDependencies
method. However, note that this approach could lead to unintended consequences, such as the SecondEvent
being added whenever didChangeDependencies
is called.
The didChangeDependencies method will be called in these situations.
After the
initState method.
When an inherited widget changes, eg Theme or Locale.
When the
BuildContext changes.
When calling
Navigator.of(context).
When the keyboard appears or disappears.
When the Mediaquery changes, eg rotation changes, size changes etc
As you can see, that might not be a good place either to listen to bloc updates if you want to get the first update as well as the subsequent ones. What about;
Connecting Blocs through Domain
From the bloclibrary:
Two blocs can listen to a stream from a repository and update their states independent of each other whenever the repository data changes.
So we can create a repository that emits a data stream and the same repository can be injected into each bloc that needs to react to new data updates. Like this example in the bloclibrary.
Stream<String> productIdeas() async* {
while (true) {
yield _ideas[_currentAppIdea++ % _ideas.length];
await Future<void>.delayed(const Duration(minutes: 1));
}
}
await emit.forEach(
_appIdeasRepo.productIdeas(),
onData: (String idea) => AppIdeaRankingIdeaState(idea: idea),
);
While the above code may seem like a solution, it introduces new challenges. Dart streams are single-subscriber by default, meaning they can only be listened to once. Converting the stream to a broadcast stream doesn't solve the issue either; it won't pause emitting events when there are no listeners, even before Blocs are created.
If a listener subscribes to the productIdeas
stream after it has started emitting items, that listener won't receive the items emitted before its subscription. Passing lazy: false
to BlocProvider
for eager initialization might work inconsistently.
To address these problems, solutions can be found in the rxdart package.
BehaviorSubject and ReplaySubject
From the documentation:
ReplaySubject: A special StreamController that captures all of the items that have been added to the controller, and emits those as the first items to any new listener.
BehaviorSubject: A special StreamController that captures the latest item that has been added to the controller, and emits that as the first item to any new listener.
So with these two subjects, you can have your blocs be up to date the moment they're created, whether lazily or not. Also, you probably don't need the ReplaySubject since it's overkill but it's good to keep it in mind.
Now that we know of these, we can do something like this:
final BehaviorSubject<FirstState> _firstStateSubject =
BehaviorSubject<FirstState>.seeded(const FirstState());
Stream<FirstState> get firstStateStream {
return _firstStateSubject.stream;
}
FirstState get currentFirstState => _firstStateSubject.value;
void updateFirstState(FirstState firstState) {
_firstStateSubject.add(firstState);
}
This code could be part of a FirstStateRepository
class that caches the state of FirstBloc
and the FirstBloc
can call updateFirstState
in it's onChange
method. Now any bloc like the SecondBloc
in the project can listen to the firstStateStream and get its latest data or just query the latest value.
If you have an existing stream that you can't manipulate easily, you can do something like this:
UserChangesService
import "package:firebase_auth/firebase_auth.dart";
import "package:rxdart/rxdart.dart";
class UserChangesService {
UserChangesService({required FirebaseAuth firebaseAuth})
: _firebaseAuth = firebaseAuth {
_firebaseAuth.userChanges().listen(_userChangesSubject.add);
}
final FirebaseAuth _firebaseAuth;
final _userChangesSubject = BehaviorSubject<User?>.seeded(null);
Stream<User?> get userChanges => _userChangesSubject.stream;
User? get currentUser => _userChangesSubject.value;
}
Inside the constructor, the code starts listening to the userChanges
stream provided by the FirebaseAuth
instance. Whenever the user state changes (for example, when the user logs in or out), the new User
object is added to _userChangesSubject
.
The repository then exposes these changes as a stream that other parts of the application can subscribe to and you can rest assured that any bloc won't miss the latest value no matter how late they're created.
I've created a new brick to aid in creating these reactive repositories, right here.
That's all for now, subscribe to my newsletter if you found this valuable.