跳转至

5 Managing Complex State With Blocs

Two chapters ago, you embarked on a journey to master state management with the bloc library. You started by using a Cubit — a simplified Bloc — to manage the quote details screen. Then, in the previous chapter, you consolidated that knowledge and demonstrated how far one could go with Cubits. You learned how to use Cubits to handle what’s perhaps the most common challenge in app development: form validation. Finally, this chapter is where you step up to the real thing: Blocs.

Now, if you think of Cubits as worse than Blocs, that’s actually not the case at all: Cubits can do 95% of what Blocs can do at 60% of the complexity — numbers taken from the same source that revealed 73.6% of all numbers are made up on the spot.

The point is: You don’t stop using Cubits once you know Blocs. If this was a shooter game, Cubits would be your handguns: lighter and easier to use, thus more effective for close combat. Yet, sometimes you just need a ~~Bloc~~ sniper rifle and don’t care about carrying the extra weight. But that’s enough metaphors for a “real-world” book…

In this chapter, you’ll learn how to:

  • Understand the difference between Cubits and Blocs, and what that looks like in the code.
  • Communicate with a Bloc.
  • Create a Bloc.
  • Generate, manipulate and consume Streams.
  • Implement a full-fledged search bar with advanced techniques such as debouncing.
  • Determine the exact situations where you should pick Blocs over Cubits.

While going through this chapter, you’ll work on the starter project from this chapter’s assets folder.

Differentiating Between Cubits and Blocs

Both Cubits and Blocs do only two things:

  • Take in events.
  • Emit states.

img

Events come in, and states go out. Nothing new so far, right?

Now, get this tattooed on your brain: The only difference between Cubits and Blocs is how they take in those UI events. Nothing else.

As you’ve seen from the last two chapters, Cubits take in events through functions you define inside them and then call from your widgets. For example:

UpvoteIconButton(
  onTap: () {
    if (quote.isUpvoted == true) {
      cubit.unvoteQuote();
    } else {
      cubit.upvoteQuote();
    }
  },
  // Omitted code.
)

Blocs, on the other hand, come pre-baked with an add() function you have to use for all events. For example:

UpvoteIconButton(
  onTap: () {
    if (quote.isUpvoted == true) {
      bloc.add(QuoteDetailsUnvoted());
    } else {
      bloc.add(QuoteDetailsUpvoted());
    }
  },
  // Omitted code.
)

Since you have to use one function for all your events, you differentiate between the events through the object you pass in as the add() function’s argument. You specify the type of these objects when you define the Bloc, as in:

img

This means that when using Blocs, besides having to create a state class — as you do for Cubits — you now have one extra level of complexity: creating an event class.

Throughout this chapter, though, you’ll see that this extra cost of using Blocs doesn’t come without benefits. Having all your events coming in through a single function gives you a whole lot more control over how to process these events. As a general rule, screens with search bars can benefit a lot from that.

For WonderWords specifically, the home screen is the ideal candidate for Blocs — lots of different events and a search bar — so that’s where your focus will be for the rest of this chapter.

Having gone through the last two chapters, creating state classes should no longer be a mystery to you. So, you’ll skip that part and dive right into the unknown territory: the event classes.

Creating Event Classes

Open the starter project and fetch the dependencies by running the make getcommand from the root directory. Ignore any errors in the code for now and open quote_list_event.dart inside the quote_list feature package.

img

You’re about to create nineteen classes within this file, so take a deep breath. Keep in mind that creating event classes is one of the few things that differentiates Blocs from Cubits, so you have to make sure this becomes second nature to you.

Kick off the work by replacing // TODO: Create the event classes. with:

// 1
abstract class QuoteListEvent extends Equatable {
  const QuoteListEvent();

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

// 2
class QuoteListFilterByFavoritesToggled extends QuoteListEvent {
  const QuoteListFilterByFavoritesToggled();
}

// 3
class QuoteListTagChanged extends QuoteListEvent {
  const QuoteListTagChanged(
    this.tag,
  );

  final Tag? tag;

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

// 4
class QuoteListSearchTermChanged extends QuoteListEvent {
  const QuoteListSearchTermChanged(
    this.searchTerm,
  );

  final String searchTerm;

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

// 5
class QuoteListRefreshed extends QuoteListEvent {
  const QuoteListRefreshed();
}

Does this look familiar to you? Creating event classes is very similar to creating state classes in that enum-like format you used for the quote_details feature in Chapter 3, “Managing State With Cubits & the Bloc Library”. In the code above, you just:

  1. Defined an abstract QuoteListEvent class to use as a common ancestral for all subsequent classes in this file. In quote_list_bloc.dart, you specify this class as your Bloc’s event type when declaring it.

img

  1. Created a QuoteListFilterByFavoritesToggled subclass to send to the Bloc when the user turns the favorites filter on or off.

img

  1. Created QuoteListTagChanged for when the user selects a new tag. The tagproperty is optional because the event can also be the user clearing a previously selected tag, in which case you’ll use null for the value.

img

  1. Created QuoteListSearchTermChanged for when the user changes the content in the search bar.

img

  1. Created QuoteListRefreshed for when the user pulls the list down to force it to refresh.

img

There are still some events left. Keep the ball rolling by appending this to the bottom of the file:

class QuoteListNextPageRequested extends QuoteListEvent {
  const QuoteListNextPageRequested({
    required this.pageNumber,
  });

  final int pageNumber;
}

You’ll use this QuoteListNextPageRequested class on two occasions:

  • When the user is scrolling down and nears the bottom of the page.

img

  • If fetching a new page fails and the user taps the “try again” widget.

img

You’ve got five more events left. Cross out two others by now appending the following to the bottom of the file:

abstract class QuoteListItemFavoriteToggled extends QuoteListEvent {
  const QuoteListItemFavoriteToggled(
    this.id,
  );

  final int id;
}

class QuoteListItemFavorited extends QuoteListItemFavoriteToggled {
  const QuoteListItemFavorited(
    int id,
  ) : super(id);
}

class QuoteListItemUnfavorited extends QuoteListItemFavoriteToggled {
  const QuoteListItemUnfavorited(
    int id,
  ) : super(id);
}

This one is a bit different. Notice that you added one more level to your class hierarchy by creating a new abstract class, QuoteListItemFavoriteToggled. This extends the previous class, QuoteListEvent.

img

Having this base QuoteListItemFavoriteToggled class allows you to share some logic between QuoteListItemFavorited and QuoteListItemUnfavorited when coding the Bloc later. You’ll use these two concrete classes to represent the taps on the favorite button of a quote card:

img

Finally, add these last three classes to the bottom of the file:

class QuoteListFailedFetchRetried extends QuoteListEvent {
  const QuoteListFailedFetchRetried();
}

class QuoteListUsernameObtained extends QuoteListEvent {
  const QuoteListUsernameObtained();
}

class QuoteListItemUpdated extends QuoteListEvent {
  const QuoteListItemUpdated(
    this.updatedQuote,
  );

  final Quote updatedQuote;
}

Those are the least intuitive ones. You’ll go through a quick explanation of how to use each of them, but don’t worry about fully grasping it for now; it’ll all get clearer when you use them in a few sections. Here’s what these classes do:

  1. QuoteListFailedFetchRetried: Used when the user taps the main Try Againbutton, which appears when an error occurs while trying to fetch the first page.

img

  1. QuoteListUsernameObtained: Used to trigger the data fetching when the screen first opens and you’ve obtained the signed-in user’s username. It’ll also be used to refresh the list when the user signs in or out of the app at a later time. It’s vital to refresh the list when the user’s authentication status changes, so you reflect that user’s favorites accordingly.

img

  1. QuoteListItemUpdated: Used when the user taps a quote and modifies it on that quote’s details screen — favoriting it, unfavoriting, upvoting, etc. You need this event so you can reflect that change on the home screen as well.

Nineteen classes and ten events later, you’re finally done. And you still wonder why “Complex” is in the chapter’s name…

Note

Everything you’ve done so far could also be accomplished with a simple Cubit. In fact, this will be true until almost the end of the chapter, when you start the Controlling the Traffic of Events section.

Time to put all those event classes to use.

Forwarding the Events to the Bloc

Open quote_list_screen.dart, which lives under the same directory you’ve been working on.

img

Find // TODO: Forward subsequent page requests to the Bloc. and replace it with:

_pagingController.addPageRequestListener((pageNumber) {
  final isSubsequentPage = pageNumber > 1;
  if (isSubsequentPage) {
    _bloc.add(
      QuoteListNextPageRequested(
        pageNumber: pageNumber,
      ),
    );
  }
});

The first thing to clear out here is that WonderWords uses the infinite_scroll_pagination package to handle the pagination of the quotes grid. The package takes care of several things: showing loading and error indicators, letting you know when the user is reaching the bottom of the page and you need more items to show, appending new items to the bottom, etc.

In the code above, you add a listener to _pagingController, which comes from that package. You need this listener so you know when the user’s scroll is nearing the bottom of the page. When that happens, you forward that event to the Bloc so it can work on getting more items for the user.

Note

If you want a deep dive into pagination and the infinite_scroll_pagination package, an excellent tutorial is Infinite Scrolling Pagination in Flutter.

Next, right below the place you inserted the previous code, replace // TODO: Forward changes in the search bar to the Bloc. with:

_searchBarController.addListener(() {
  _bloc.add(
    QuoteListSearchTermChanged(
      _searchBarController.text,
    ),
  );
});

This _searchBarController property holds a regular TextEditingController you attached to the screen’s search bar. In the code above, you add a listener to it so you can notify your Bloc of any changes to the TextField’s value.

Now, scroll down a bit more and locate // TODO: Forward pull-to-refresh gestures to the Bloc.. Replace it with:

_bloc.add(
  const QuoteListRefreshed(),
);

This time, you’re sending the QuoteListRefreshed event to the Bloc whenever the user pulls the list down from the top to force it to refresh. That gesture is known as pull-to-refresh.

That was all for this file. Now, for the last three events, go to quote_paged_grid_view.dart.

img

Replace // TODO: Forward taps on the favorite button. with:

bloc.add(
  isFavorite
    ? QuoteListItemUnfavorited(quote.id)
    : QuoteListItemFavorited(quote.id),
);

That one was pretty easy, right? When the user taps the favorite button for a quote, you send a QuoteListItemUnfavorited if that quote is already a favorite or QuoteListItemFavorited if it’s not.

Now, for a more complex case, find // TODO: Open the details screen and notify the Bloc if the user modified the quote in there.. Replace it with:

// 1
final updatedQuote = await onQuoteSelected(quote.id);

if (updatedQuote != null &&
    // 2
    updatedQuote.isFavorite != quote.isFavorite) {
  // 3
  bloc.add(
    QuoteListItemUpdated(
      updatedQuote,
    ),
  );
}

This code executes whenever the user taps a quote. When that happens, your code will:

  1. Call the onQuoteSelected callback this widget received on its constructor. If you trace back that callback’s origin, you’ll find its declaration in the mainapplication package. What it does is simply open the quote details screen and then return the updated quote object to you when the user closes that screen.
  2. Check if the user has favorited or unfavorited the quote while in the details screen.
  3. If the user did change the quote’s favorite status, send the updated quote object to the Bloc so it can replace the old one currently on screen.

To finish this file, you now have to send the taps on the main Try Again button to the Bloc. This button appears when you can’t fetch the first page for any reason. Do this by replacing // TODO: Request the first page again. with:

bloc.add(
  const QuoteListFailedFetchRetried(),
);

Great job! Just one more section, and you’ll be able to build and run your app.

This section was intentionally repetitive. Forwarding events is one of only two things that change in your codebase when using Blocs instead of Cubits. The second thing is the actual Bloc, which you’ll dive into now.

Scaffolding the Bloc

Still in the same directory you’ve been working on, open quote_list_bloc.dart.

img

The Bloc’s declaration is already there to save you some time. Take a look at it:

// 1
class QuoteListBloc extends Bloc<QuoteListEvent, QuoteListState> {
  QuoteListBloc({
    required QuoteRepository quoteRepository,
    required UserRepository userRepository,
  })  :
        // 2 
        _quoteRepository = quoteRepository,
        // 3
        super(
          const QuoteListState(),
        ) {
    // 4
    _registerEventHandler();

    // TODO: Watch the user's authentication status.
  }

  // 5
  late final StreamSubscription _authChangesSubscription;
  String? _authenticatedUsername;

  final QuoteRepository _quoteRepository;

  // Omitted code.
}

Nothing too fancy about it. Here’s a walkthrough:

  1. This QuoteListBloc class extends Bloc and specifies two generic types: the event class, QuoteListEvent, and the state class, QuoteListState.
  2. QuoteListBloc‘s constructor then receives two repositories and assigns one of them to the _quoteRepository property. You didn’t have to assign userRepository to a property of the QuoteListBloc class — as you did for quoteRepository — because you’ll only use it inside the constructor’s code.
  3. You then call the super constructor and pass it to your initial state, which is just a QuoteListState instantiated with all the default values.
  4. Here, you’re calling a function you’ll implement later to handle all your events.
  5. You’ll learn all about these _authChangesSubscription and _authenticatedUsername properties in a moment.

Start your part of the work by replacing // TODO: Watch the user's authentication status. with:

_authChangesSubscription = userRepository
    // 1
    .getUser()
    // 2
    .listen(
  (user) {
    // 3
    _authenticatedUsername = user?.username;

    // 4
    add(
      const QuoteListUsernameObtained(),
    );
  },
);

Here’s what’s going on in there:

  1. UserRepository has a getUser() function that returns a Stream<User?>. That Stream is useful for monitoring changes in the user’s authentication status. When the user signs in, a new User object comes down that Stream. When they sign out, you get a null value instead.
  2. You then subscribe to that Stream using the listen() function. The listen() function returns an object, called the subscription. You store the subscription object in the _authChangesSubscription property, so you can dispose of it later.
  3. Every time you get a new value from that Stream, you store the new username inside the _authenticatedUsername property. This allows you to read that value from other parts of your Bloc’s code.
  4. This is a bit different from what you did before… Here, you’re adding an event to the Bloc from inside the Bloc itself — so far, you’ve only used this add()function from the widgets’ side.

Before you continue adding functionality to your Bloc’s code, it’s time to do some housekeeping. Dispose of the subscription you just created when your screen is closed. To do this, find // TODO: Dispose the auth changes subscription. and replace it with:

@override
Future<void> close() {
  _authChangesSubscription.cancel();
  return super.close();
}

Here, you’re just overriding your Bloc’s close() function to insert the code that cancels your subscription. This ensures your subscription won’t remain active after the user closes the screen.

Now, before you write the code that actually handles incoming events, you’ll create a utility function to support the logic you’ll write there.

Fetching Data

Replace // TODO: Create a utility function that fetches a given page. with:

Stream<QuoteListState> _fetchQuotePage(
  int page, {
  required QuoteListPageFetchPolicy fetchPolicy,
  bool isRefresh = false,
}) async* {
  // 1
  final currentlyAppliedFilter = state.filter;
  // 2
  final isFilteringByFavorites = currentlyAppliedFilter is QuoteListFilterByFavorites;
  // 3
  final isUserSignedIn = _authenticatedUsername != null;
  if (isFilteringByFavorites && !isUserSignedIn) {
    // 4
    yield QuoteListState.noItemsFound(
      filter: currentlyAppliedFilter,
    );
  } else {
    // TODO: Fetch the page.
  }
}

This is the function you’ll use to actually talk to the repository and get a new page from either the server or the cache. It returns a Stream instead of a Future because it can have up to two emissions if the fetchPolicy is QuoteListPageFetchPolicy.cacheAndNetwork – the page it got from the cache, followed by the fresh page it got from the server. In the code you just wrote, you:

  1. Retrieve the currently applied filter, which can be either a search filter, favorites filter or tag filter.
  2. Check if the user is currently filtering by favorites.
  3. Check if the user is signed in.
  4. Use the yield keyword to emit a new state to the new Stream you’re generating within this function.

Notice this _fetchQuotePage() function you’re working on doesn’t emit anything to the UI. It just generates a Stream of QuoteListStates, which another part of your code can then subscribe to and finally send these emissions to the UI. You could only use the yield keyword because you added the async* to the function’s declaration. You can check out this article if you want to learn more about this Stream generation mechanism of the Dart language.

Continue your work on this function by this time replacing // TODO: Fetch the page. with:

final pageStream = _quoteRepository.getQuoteListPage(
  page,
  tag: currentlyAppliedFilter is QuoteListFilterByTag
      ? currentlyAppliedFilter.tag
      : null,
  searchTerm: currentlyAppliedFilter is QuoteListFilterBySearchTerm
      ? currentlyAppliedFilter.searchTerm
      : '',
  favoritedByUsername:
      currentlyAppliedFilter is QuoteListFilterByFavorites
          ? _authenticatedUsername
          : null,
  fetchPolicy: fetchPolicy,
);

try {
  // 1
  await for (final newPage in pageStream) {
    final newItemList = newPage.quoteList;
    final oldItemList = state.itemList ?? [];
    // 2
    final completeItemList = isRefresh || page == 1
        ? newItemList
        : (oldItemList + newItemList);

    final nextPage = newPage.isLastPage ? null : page + 1;

    // 3
    yield QuoteListState.success(
      nextPage: nextPage,
      itemList: completeItemList,
      filter: currentlyAppliedFilter,
      isRefresh: isRefresh,
    );
  }
} catch (error) {
  // TODO: Handle errors.
}

This is where the magic happens. Here, you:

  1. Listen to the Stream you got from the repository by using this await forsyntax. What it does is run the code inside the for block every time your pageStream emits a new item. The only time it actually emits more than one item, though, is when you build the Stream using the QuoteListPageFetchPolicy.cacheAndNetwork fetch policy, which you’ll do when the user first opens the screen.
  2. Then, for every new page you get, you append the new items to the old ones you already have on the screen. This is assuming the user isn’t trying to refresh the data, in which case you’ll instead replace the previous items.
  3. yield a new QuoteListState containing all the new data you got from the repository.

You also have to be prepared for the case in which you can’t get that new page for some reason. For example, the user might not have an internet connection. Do this by replacing // TODO: Handle errors. with:

if (error is EmptySearchResultException) {
  // 1
  yield QuoteListState.noItemsFound(
    filter: currentlyAppliedFilter,
  );
}

if (isRefresh) {
  // 2
  yield state.copyWithNewRefreshError(
    error,
  );
} else {
  // 3
  yield state.copyWithNewError(
    error,
  );
}

Here’s what’s happening in this code:

  1. If the error is an EmptySearchResultException, you’ll treat it differently. Instead of emitting an “error” state, which would cause the UI to show a Try Again button, you’ll emit an empty state, which will show the user a more descriptive message saying you couldn’t find any items for the current filters. It’s the same state you use when a signed out user tries to filter by favorites.
  2. You’ll also emit a different state if this error occurred during a refresh request. When the user intentionally asks for a refresh, it means they already have some items on the screen, so there’s no reason for you to hide those items and show a full-screen error widget. In that case, the best thing to do is notify them of the error with a snackbar.

img

  1. Finally, if this is an unexpected error, just re-emit the current state with an error added to it. The UI will take care of showing a full-screen error widget if the user is trying to fetch the first page. Otherwise, it will append an error item to the grid if this is a subsequent page request.

img

Wonderful! Your code should be free of errors now. Build and run to make sure you’re on the right track. Since you haven’t called the new _fetchQuotePage() function yet, you won’t see anything but an infinite loading indicator on the screen.

Also, an error will print out to your console saying you haven’t registered an event handler yet — you can just ignore it for now; you’ll fix that next.

img

Note

If you’re having trouble running the app, it’s because you forgot to propagate the configurations you did in the first chapter’s starter project to the following chapters’ materials. If that’s the case, please revisit Chapter 1, “Setting up Your Environment”.

Receiving Events

Inside the _registerEventHandler() function, replace // TODO: Take in the events. with:

// 1
on<QuoteListEvent>(
  // 2
  (event, emitter) async {
    // 3
    if (event is QuoteListUsernameObtained) {
      await _handleQuoteListUsernameObtained(emitter);
    } else if (event is QuoteListFailedFetchRetried) {
      await _handleQuoteListFailedFetchRetried(emitter);
    } else if (event is QuoteListItemUpdated) {
      _handleQuoteListItemUpdated(emitter, event);
    } else if (event is QuoteListTagChanged) {
      await _handleQuoteListTagChanged(emitter, event);
    } else if (event is QuoteListSearchTermChanged) {
      await _handleQuoteListSearchTermChanged(emitter, event);
    } else if (event is QuoteListRefreshed) {
      await _handleQuoteListRefreshed(emitter, event);
    } else if (event is QuoteListNextPageRequested) {
      await _handleQuoteListNextPageRequested(emitter, event);
    } else if (event is QuoteListItemFavoriteToggled) {
      await _handleQuoteListItemFavoriteToggled(emitter, event);
    } else if (event is QuoteListFilterByFavoritesToggled) {
      await _handleQuoteListFilterByFavoritesToggled(emitter);
    }
  },
  // TODO: Customize how events are processed.
);

This is the heart of your Bloc. Here, you’re finally listening to the incoming events and mapping them to functions you created to convert them to new states. In this code, you:

  1. Call the on() function and use the angle brackets to specify the type of the events you want to register the handler for. In this case, it’s QuoteListEvent, which encompasses all the event types you created at the beginning of this chapter.

img

  1. Pass in a callback to the on() function. That callback takes in the actual event object sent by the UI and an emitter object you have to use to send new states back to the UI.
  2. Create if blocks for each type of event you can receive and then call the corresponding functions that handle each one of them.

Build and run one more time just to make sure you didn’t break anything. This will be the last time you won’t see anything different on the screen.

img

Now, you’ll dive into some of these event-handling functions you’re calling from your if blocks. Most of them are already complete, but a key one depends on your magical touch to start working.

Handling Individual Events

Scroll down and find // TODO: Handle QuoteListUsernameObtained.. Replace it with:

// 1
emitter(
 QuoteListState(
    filter: state.filter,
  ),
);

// 2
final firstPageFetchStream = _fetchQuotePage(
  1,
  fetchPolicy: QuoteListPageFetchPolicy.cacheAndNetwork,
);

// 3
return emitter.onEach<QuoteListState>(
  firstPageFetchStream,
  onData: emitter,
);

Remember, you fire this QuoteListUsernameObtained event from your constructor on two occasions:

  • When the user first opens the screen and you’ve retrieved their username – or null if the user is signed out.
  • When the user signs in or out at any later time.

When any of these happen, the code you just wrote will:

  1. Use the emitter to set the UI back to its initial state — with a full-screen loading indicator — while keeping any filters the users might have selected, like a tag, for example. Re-emitting the initial state makes no difference when the user first opens the app — since the screen will still be in the initial state — but is essential for when the user signs in or out at a later time.

Note

Notice that even though the emitter is an object and not a function, you can still call it using the same syntax you use for functions: emitter(something). You can do this because the emitter object implements the call() function internally.

  1. Call the _fetchQuotePage() function you created in the previous section to get a new Stream you can subscribe to, to get the initial page.
  2. Use the onEach() function from the emitter to handle subscribing to the firstPageFetchStream and sending out each new state it emits to the UI.

Take a moment to digest this.

You know the reason _fetchQuotePage() returns a Stream and not a Future is because it can emit up to two times when you specify the cacheAndNetwork fetch policy, which is exactly what you’re doing here. So, what that emitter.onEach() call does is subscribe to firstPageFetchStream and use the emitter itself as a function to send the values from the Stream to the UI on each new emission.

To see this mechanism in play, build and run the app twice on your device. Twice? Yes! The first time, you should just wait until the app loads some quotes on the screen, and then you can close it — this ensures you have some quotes stored locally, or cached. Then, when you run the app for the second time, notice it almost instantly shows you the quotes you had the first time you opened the app — this is the first firstPageFetchStream emission. Then, after a few seconds, you can see the app replace those “old” quotes with fresh ones it got behind the scenes — your second emission. Cool, huh?

img

Before you proceed, take some time to look at the functions that handle the other events. They’re basically variations of what you did for _handleQuoteListOpened(), and the code has lots of comments so you can understand everything that’s going on.

Controlling the Traffic of Events

Now, changing subjects, your home screen is in pretty good shape already, but have you tried using the search bar? You’d find two problems with it:

  • As you type, the app triggers a separate HTTP request for every new character you enter. So, for example, a search for the word “life” would result in four sequential searches: “l”, “li”, “lif”, and finally, “life”.
  • As you fire off these requests, there’s no guarantee their results will arrive in the same order. For example, if the response for “lif” takes longer to process than the response for “life”, the results you’d be showing the user wouldn’t correspond to what’s in the search bar.

You need finer-grained control over how your Bloc processes these events.

Instead of processing QuoteListSearchTermChanged events one by one, you want to process only the last one within a given timespan. For example, only send a request if one second has elapsed without the user typing anything new. This technique is known as debouncing.

Debouncing completely solves your first issue — of firing off too many requests. But it only diminishes the likelihood of the second one, where the results of an obsolete search might take over the results of a subsequent one. You’re now guaranteeing that searches have at least one second separating them, but what if the server takes two seconds more to process your second-last search event?

To knock out this second issue, you’ll need to stop processing an event if a newer one comes in. You’ll call this the canceling effect.

You’ll now see how to apply both the debouncing and the canceling effect on Blocs. In fact, the ability to apply these effects and change how your Bloc processes these events is exactly why you chose to use a Bloc for this screen. You couldn’t do the same if you were using a Cubit — at least, not with the bloc library’s support.

Knowing the Transformer Function

Get back to the code, and, continuing on your Bloc’s file, replace // TODO: Customize how events are processed. with:

transformer: (eventStream, eventHandler) {
  // TODO: Debounce search events.

  // TODO: Discard in-progress event if a new one comes in.
},

Here, you’re specifying the transformer argument of the on() function. This transformer argument takes in a function where you have the ability to customize how you want to process events. You’re receiving two values within that function:

  • eventStream: The internal Stream from your Bloc where all the events come through.
  • eventHandler: The function you wrote right above this one to process the events; the one that takes in an event and an emitter where you put all those if blocks.

In other words, within this transformer function, you have:

  • The channel through which your events come in — the eventStream.
  • The function you have to send your events to be taken care of — the eventHandler.

The only thing you have to do now is customize how you connect the two.

It’s important to understand you don’t necessarily have to specify a transformer. If you choose to go with the default implementation, your Bloc will perform just like a Cubit: processing events one by one as they arrive and not considering the order they came in when handling their results. The ability to customize the transformer, though, is the factor you should consider when deciding to pick Blocs over Cubits for a specific screen.

Applying the Debouncing Effect

Pick up where you left off by replacing // TODO: Debounce search events. with:

// 1
final nonDebounceEventStream = eventStream.where(
  (event) => event is! QuoteListSearchTermChanged,
);

final debounceEventStream = eventStream
    // 2
    .whereType<QuoteListSearchTermChanged>()
    // 3
    .debounceTime(
      const Duration(seconds: 1),
    )
    // 4
    .where((event) {
  final previousFilter = state.filter;
  final previousSearchTerm =
      previousFilter is QuoteListFilterBySearchTerm
          ? previousFilter.searchTerm
          : '';

  final isSearchNotAlreadyDisplayed = event.searchTerm != previousSearchTerm;
  return isSearchNotAlreadyDisplayed;
});

// 5
final mergedEventStream = MergeStream([
  nonDebounceEventStream,
  debounceEventStream,
]);

Here’s what’s going on with the code above:

  1. There are several functions you can call on Streams to generate a modified copy of them; we call these functions operators. Here, you use the whereoperator to generate a new Stream that excludes any QuoteListSearchTermChanged events.
  2. Here, you’re using the whereType operator to do the opposite of what you did in the previous step: generating a new Stream that excludes all but the QuoteListSearchTermChanged events. Both the whereType and the debounceTime operators you’ll use next come from the RxDart package, which adds several capabilities to Dart’s Streams.
  3. Now that you have a separate Stream for the QuoteListSearchTermChangedevents, you applied the debounceTime operator to it so you can achieve that debouncing effect of one second without affecting all the other types of events.
  4. Here, you’re using the where operator to add another great feature to your searches: You’re skipping searches where the term entered by the user is equal to the term of the search already on display. This can happen, for example, if the user adds a letter to the search bar, then regrets it and deletes it within the one-second timespan. If you didn’t apply this where operator, this would trigger another request even though the search term hasn’t changed.
  5. In steps 1 and 2, you broke your eventStream into two other Streams just so that you could apply some operators exclusively to the search events. Now that you’ve finished that, you’re merging the two Streams back together so you can continue implementing your transformer.

Great job! Now to the final touch…

Applying the Canceling Effect

By default, Blocs process all incoming events in parallel. It works extremely well for the majority of situations — so much so that this is how all Cubits work — but it ends up being a problem for search bars:

img

See the issue? Even though the search for “life” was last, you’ll end up displaying the results for the “lif” query just because it took longer to process.

To solve this, you change this default behavior and cancel any previous event’s processing when a new one comes in:

img

To do this, replace // TODO: Discard in-progress event if a new one comes in.with:

// 1
final restartableTransformer = restartable<QuoteListEvent>();

// 2
return restartableTransformer(mergedEventStream, eventHandler);

Here’s what’s happening:

  1. This restartable function comes from the bloc_concurrency package, which is a dependency of this quote_list package’s pubspec.yaml. This restartablefunction is the one that has the desired canceling effect, but the bloc_concurrency package provides a few different options as well:

img

  1. The restartable function actually returns another function. You then return the results from that function by passing them to your mergedEventStreamand the eventHandler.

That’s all for this chapter. Congratulations, you’ve made it! Build and run your app for the last time now and appreciate how smoothly your search bar works.

img

Key Points

  • You don’t stop using Cubits once you know Blocs; one isn’t better than the other.
  • The only difference between Cubits and Blocs is how they receive events from the UI layer.
  • While Cubits require you to create one function for each event, Blocs require you to create one class for each.
  • As a rule of thumb, default to Cubits for their simplicity. Then, if you find you need to control the traffic of events coming in, upgrade to Blocs.
  • If you don’t customize the transformer, your Bloc will perform just like a Cubit: processing events one by one as they arrive and not considering their order when handing over the results.
  • The ability to customize how events are processed is why you should pick a Bloc over a Cubit.