Better with bloc — part II

Stephan E.G. Veenstra
8 min readSep 2, 2023

--

In this article I continue explaining my ways of using bloc for state management in Flutter. Just as before, I will be writing based on my personal preferences and coding style.

🚨 This article will assume you have read the first article.

Providing blocs

In the previous article I explained that I’ve made a rule when it comes to consuming blocs. To recap, only destination widgets are allowed to consume blocs.

For providing blocs, I have a similar rule. Blocs are provided only in the following two locations:

Destination Widgets

While destination widgets can consume blocs, they are also allowed to provide other blocs. So why would we want to do this?

Let’s say we are creating a questionnaire app where users can answer different types of questions, like free-text, multiple-choice, rating, etc.

Next, imagine we have the following destination in our app: /questionnaire/<questionnaire-id>/<question-number>.

The first part tells us we’re doing a questionnaire, the second part tells us which one, and the last part tells us which question we’re at. So when we’re on page /questionnaire/12/4, it means we’re at question 4 of questionnaire 12.

This page shows us a question, so we’re on the QuestionPage. To back up this page, we need a bloc, which we would call QuestionBloc (or QuestionCubit if you use cubits), which holds QuestionState.

QuestionState will have the following familiar subtypes, like QuestionInitial, QuestionLoading, QuestionLoaded, and QuestionError. When the state is QuestionLoaded, it will hold the Question object, which in turn can also be one of few subtypes like, FreeTextQuestion, MultipleChoiceQuestion, RatingQuestion, etc.

The QuestionPage could look something like this:

Widget build(BuildContext context) {
return BlocBuilder<QuestionBloc, QuestionState>(
builder(context, state) => switch(state) {
QuestionInitial() => _Loading(),
QuestionLoading() => _loading(),
QuestionLoaded(:final question) => _Loaded(question),
QuestionError(:final error) => _Error(error),
}
);
}

The fact that our loaded object (Question) can be all these different kinds of subtypes, is what would make handling this in the QuestionPage and QuestionBloc complex and messy. There would be a lot of logic just for checking the current type of the question on many occasions, like when showing the actual question or answering a question.

Instead, what we can do, is simply delegate the responsibility to question-specific blocs. By creating a bloc for each type of question, we eliminate the need for type checks, because we only have to do that once at the point of delegation.

So now we basically turn the QuestionPage into a wrapper page, whose only responsibility is loading the question, and then delegate is to the correct question-specific bloc, like so:

Widget build(BuildContext context) {
return BlocBuilder<QuestionBloc, QuestionState>(
builder(context, state) => switch(state) {
QuestionInitial() => _Loading(),
QuestionLoading() => _loading(),
QuestionLoaded(:final question) => switch(question){
FreeTextQuestion() => BlocProvider<FreeTextQuestionBloc>(
create: (context) => serviceLocator.create(),
child: FreeTextQuestionPage(),
),
MultipleChoiceQuestion() => BlocProvider<MultipleChoiceQuestionBloc>(
create: (context) => serviceLocator.create(),
child: MultipleChoiceQuestionPage(),
),
RatingQuestion() => BlocProvider<RatingQuestionBloc>(
create: (context) => serviceLocator.create(),
child: RatingQuestionPage(),
),
// etc...
},
QuestionError(:final error) => _Error(error),
}
);
}

And thus, we end up with multiple simple-to-deal-with blocs, instead of a behemoth class that grows with every new type of question.

Router

The other location where I provide blocs is in my router configuration.

For my apps I’m using the go_router package. I’m not going to explain here how go_router works, but I will show you how and why I started providing blocs in it’s configuration.

Take a look at this example router configuration for a todo app:

final todoRouter = GoRouter(
routes: [
// This is the root route which shows the list of Todo's
GoRoute(
path: '/',
builder: (context, state) => BlocProvider<TodoListBloc>(
create: (context) => serviceLocator.get(),
child: TodoListPage(),
),
routes: [
// This is the first sub-route, which shows an empty detail page.
// This will be shown when the user presses the + button.
GoRoute(
path: 'new',
// Here we simply provide a new instance of the TodoDetailBloc.
builder: (context, state) => BlocProvider<TodoDetailBloc>(
create: (context) => serviceLocator.get(),
child: TodoDetailPage(),
),
),
// This is the second sub-route, which shows a selected todo.
// This will be shown when the user presses an existing todo.
GoRoute(
path: ':id',
// Here we also create a new instance of the TodoDetailBloc,
// but we will also call the `load` function on it with the 'id'
// that has been passed.
builder: (context, state) => BlocProvider<TodoDetailBloc>(
create: (context) => serviceLocator.get(state.)..load(
id: state.pathParameters['id'],
),
child: TodoDetailPage(),
),
),
],
),
],
);

As you can see, every route will first provide the bloc to be used, calling functions when necessary. When I provide the blocs like this, there is no need for me to add any parameters or logic to the widgets like the TodoListPage and TodoDetailPage. They now only need to consume whatever bloc is provided above them in the widget tree.

Just like with the example earlier with the destination widget, the router is responsible for delegating, thus providing the correct bloc and page.

Updating state

When managing state, you will often update and transition state objects. These mutations often come with a set of rules. Like when we’re in the initial state of the bloc, the only next possible state can be loading or when we’re in the loading state, the only possible follow-up states are either loaded or error.

We can use our knowledge of these possible mutations and create specific mutation functions. When I want to create these kind of functions, I will do so by creating extensions for every subtype.

Let’s take a look at an example for the initial state from a login page:

// LoginInitial means the user is filling in the form (username + password).
extension LoginInitialExt on LoginInitial {

// The user has updated the username field
LoginInitial applyUsername(String username) {
return copyWith(username: username);
}

// The user has updated the password field
LoginInitial applyPassword(String password) {
return copyWith(password: password);
}

// The user has submitted the login form
LoginLoading applySubmit() {
return LoginLoading(
username: username,
);
}
}

Here the functions clearly tell you what the LoginInitial state is capable off. It can update itself when form-fields are updated, or transition into LoginLoading when the form has been submitted.

The bloc or cubit can now call these functions instead of having to contain all the logic for all the different states and transitions, like this:

// Called every time the username field is updated
void updateUsername(String username) {
final currentState = state;
switch(currentState) {
// When the currentState is LoginInitial, we can update the state.
case LoginInitial():
final newState = currentState.applyUsername(username);
emit(newState);
// Also, when login failed, we should be able to enter a new username.
case LoginError():
final newState = currentState.applyUsername(username);
emit(newState);
// Any other state should not call this, so we can ignore it.
default:
}
}

We have separated the concerns for updating the state into two parts:

  1. The bloc/cubit is responsible for determining in which state which mutation can take place. It will then call the mutation function.
  2. The extensions contain the implementation of the mutation functions which are responsible for the actual mutation of the state.

Creating blocs

You might have seen earlier in this article, that I’m using a service locator to instantiate blocs/cubits. I use a package for this called get_it, but you can also use an alternative like ioc_container, or write something yourself.

In the main.dart, I will setup get_it so that it can return a new instance of a bloc when requested:

// Make GetIt globally available
final serviceLocator = GetIt.instance;

void main() {
// From repositories we only have a single instance which will be
// shared across blocs.
final todoRepo = TodoRepo();

// Register factories for the blocs
serviceLocator.registerFactory<TodoListBloc>(
() => TodoListBloc(todoRepo: todoRepo),
);
serviceLocator.registerFactory<TodoDeetailBloc>(
() => TodoDetailBloc(todoRepo: todoRepo),
);
}

And now we can simply request a new instance by using:

serviceLocator.get<TodoDetailBloc>();

…in BlocProviders.

Syncing blocs

Sometimes your app has pages that need to share information. Like when you have an app with product which can be favorited. Both the ProductListViewPage as the FavoriteProductsPage should be updated whenever a product is (un)favorited.

The first thing I want to mention about this is, that blocs should NOT be connected to one-another, as per documentation:

Because blocs expose streams, it may be tempting to make a bloc which listens to another bloc. You should not do this.
- docs

Blocs and cubits should only be updated by the repositories they depend on, or events/functions triggered from the UI layer.

Coming back to the product app example, where both the ProductListViewPage as the FavoriteProductsPage rely on the favorited products. The ProductListViewPage should show a filled heart for product that already have been favorited and FavoriteProductsPage should just show all the favorited products.

This means both the ProductListViewCubit and the FavoriteProductsCubit will have a dependency on the (same) FavoritesRepository, which ideally would expose a stream of the currently favorited products.

Both cubits can subscribe to this stream and trigger (private) functions to update the state. An example:

class FavoriteProductsCubit extends Cubit<FavoriteProductsState> {
FavoriteProductsCubit(this._favoritesRepo) : super(FavoriteProductsState.initial()) {
// We can put the state in loading because we're starting to listen for
// favorites right away.
emit(FavoriteProductsState.loading());
// Here we subscribe on the favorites stream from the FavoritesRepository
// and pass in a private function to handle the updates.
_favoritesRepoSubscription = _favoritesRepo.favorites.listen(_onFavoritesUpdated);
}

final FavoritesRepository _favoritesRepo;
final StreamSubscription? _favoritesRepoSubscription;

// The function that is called every time the list of favorites changes.
void _onFavoritesUpdated(List<Favorite> favorites) {
final currentState = state;
switch(currenState) {
case FavoriteProductsLoading():
// This mutation will transition the state from FavoriteProductsLoading
// to FavoriteProductsLoaded.
// The actual mutating is delegated to mutation functions as explained
// earlier.
final newState = currentState.applyFavorites(favorites);
emit(newState);

// If the favorites are updated and we were already looking at the list
// we still want to update the list when it changes.
case FavoriteProductsLoaded():
final newState = currentState.applyFavorites(favorites);
emit(newState);
default:
}
}

// Favorite the given product. Only available when state is loaded.
void favoriteProduct(Product product) {
final currentState = state;
switch(currenState) {
case FavoriteProductsLoaded():
// We call the repository to update the 'data'.
_favoritesRepo.favorite(product);
// We don't have to update the state here, because the update of the
// repository will trigger the listener, which will cause the state
// be updated.
// This way we keep a nice unidirectional data-flow.
default:
}
}

// Unfavorite the given product. Only available when state is loaded.
void unfavoriteProduct(Product product) {
final currentState = state;
switch(currenState) {
case FavoriteProductsLoaded():
_favoritesRepo.unfavorite(product);
default:
}
}

void close() {
// Always close the subscriptions!
_favoritesRepoSubscription?.cancel();
super.close();
}
}

In this example for the FavoriteProductsCubit a few interesting things are happening. We rely on the FavoritesRepository for the latest favorites data. So, when an favoriteProduct/unfavoriteProduct function is called, we do not need to update the state in the function itself. This is because our action will cause the FavoritesRepository to be updated. This will update the stream that we are listening to, which in it’s turn will call the _onFavoritesUpdated listener function resulting in the new, up-to-date, state.

Because we’re putting the FavoritesRepository in charge of the data, it becomes a single source of truth and both cubits can stay in sync without having to rely on one-another!

Conclusion

In this article I’ve given some more advanced ways in how I use bloc in my applications. We’ve covered:

  1. The way that I’m providing blocs in destination widgets and via go_router.
  2. The way I’m updating state with the use of mutation functions, and how they result in a nice way to separate concerns.
  3. How I create blocs with the help of get_it.
  4. And how I keep my blocs in sync by relying on repositories to be the single source of truth.

Again, I hope that these articles are interesting or even helpful. If you do like this format, where I write about how I deal with real-life scenarios and use-cases, please leave some claps 👏 (the more the better 😉). If you have any questions, or suggestions, leave a comment below👇! Thank you! 🙏

--

--

Stephan E.G. Veenstra

When Stephan learned about Flutter back in 2018, he knew this was his Future<>. In 2021 he quit his job and became a full-time Flutter dev.