Chapters

Hide chapters

Flutter Apprentice

Fourth Edition · Flutter 3.16.9 · Dart 3.2.6 · Android Studio 2023.1.1

Section II: Everything’s a Widget

Section 2: 5 chapters
Show chapters Hide chapters

Section IV: Networking, Persistence & State

Section 4: 6 chapters
Show chapters Hide chapters

14. Working With Streams
Written by Kevin D Moore

Imagine yourself sitting by a creek, having a wonderful time. While watching the water flow, you see a piece of wood or a leaf floating down the stream and decide to take it out of the water. You could even have someone upstream purposely float things down the creek for you to grab.

You can imagine Dart streams in a similar way: as data flowing down a creek, waiting for someone to grab it. That’s what a stream does in Dart — it sends data events for a listener to grab.

With Dart streams, you can send one data event at a time while other parts of your app listen for those events. Such events can be collections, maps or any other type of data you’ve created.

Streams can send errors in addition to data; you can also stop the stream if you need to.

In this chapter, you’ll update Recipe Finder to use streams in two different locations. You’ll use one for bookmarks to let the user mark favorite recipes and automatically update the UI to display them. You’ll also use one to update your ingredient and grocery lists.

But before you jump into the code, you’ll learn more about how streams work.

Types of Streams

Streams are part of Dart, and Flutter inherits them. There are two types of streams in Flutter: single subscription streams and broadcast streams.

Widget Widget Widget Widget Stream Broadcast Stream

Single subscription streams are the default. They work well when you’re only using a particular stream on one screen.

A single subscription stream can only be listened to once. It doesn’t start generating events until it has a listener, and it stops sending events when the listener stops listening, even if the source of events could still provide more data.

Single subscription streams are useful for downloading a file or for any single-use operation. For example, a widget can subscribe to a stream to receive updates about a value, like the progress of a download, and update its UI accordingly.

If you need multiple parts of your app to access the same stream, use a broadcast stream, instead.

A broadcast stream allows any number of listeners. It fires when its events are ready, whether there are listeners or not.

To create a broadcast stream, you simply call asBroadcastStream() on an existing single subscription stream.

final broadcastStream = singleStream.asBroadcastStream();

You can differentiate a broadcast stream from a single subscription stream by inspecting its Boolean property isBroadcast.

In Flutter, some key classes are built on top of Stream that simplify programming with streams.

The following diagram shows the main classes used with streams:

StreamController StreamSubscription Stream StreamSink listen() StreamBuilder widget

Next, you’ll take a deeper look at each one.

StreamController and Sink

When you create a stream, you usually use StreamController, which holds both the stream and StreamSink.

A sink is a destination for data. When you want to add data to a stream, you’ll add it to the sink. Since the StreamController owns the sink, it listens for data on the sink and sends the data to its stream listeners.

Here’s an example that uses StreamController:

final _recipeStreamController = StreamController<List<Recipe>>();
final _stream = _recipeStreamController.stream;

To add data to a stream, you add it to its sink:

_recipeStreamController.sink.add(_recipesList);

This uses the sink field of the controller to “place” a list of recipes on the stream. That data will be sent to any current listeners.

When you’re done with the stream, make sure you close it, like this:

_recipeStreamController.close();

StreamSubscription

Using listen() on a stream returns a StreamSubscription. You can use this subscription class to cancel the stream when you’re done, like this:

StreamSubscription subscription = stream.listen((value) {
    print('Value from controller: $value');
});
...
...
// You are done with the subscription
subscription.cancel();

Sometimes, it’s helpful to have an automated mechanism to avoid managing subscriptions manually. That’s where StreamBuilder comes in.

StreamBuilder

StreamBuilder is handy when you want to use a stream. It takes two parameters: a stream and a builder. As you receive data from the stream, the builder takes care of building or updating the UI.

Here’s an example:

final repository = ref.watch(repositoryProvider);
return StreamBuilder<List<Recipe>>(
  stream: repository.recipesStream(),
  builder: (context, AsyncSnapshot<List<Recipe>> snapshot) {
    // extract recipes from snapshot and build the view
  }
)
...

StreamBuilder is handy because you don’t need to use a subscription directly, and it unsubscribes from the stream automatically when the widget is destroyed.

Note: Riverpod has a StreamProvider, which you can use to provide a stream to a widget. You can learn more about it at https://riverpod.dev/docs/providers/stream_provider.

Now that you understand how streams work, you’ll convert your existing project to use them.

Adding Streams to Recipe Finder

You’re now ready to start working on your recipe project. If you’re following along with your app from the previous chapters, open it and keep using it with this chapter. If not, just locate the projects folder for this chapter and open starter in Android Studio.

Note: If you use the starter app, don’t forget to add your apiKey in network/spoonacular_service.dart.

To convert your project to use streams, you need to change MemoryRepository to add two new methods that return one stream for recipes and another for ingredients. Instead of just returning a list of static recipes, you’ll use streams to modify that list and refresh the UI to display the change.

This is what the flow of the app looks like:

RecipeList Add Bookmark Recipe Details Bookmarks Groceries Recipes Bookmarked Receipes Ingredients BottomNavigationBar

Here, you can see that the Recipes screen has a list of recipes. Bookmarking a recipe adds it to the bookmarked recipe list and updates both the bookmarks and the groceries screens.

You’ll start by converting your repository code to return Streams and Futures.

Adding Futures and Streams to the Repository

Open data/repositories/repository.dart and change all of the return types to return a Future, except for the init and close methods. For example, change the existing findAllRecipes() to:

Future<List<Recipe>> findAllRecipes();

Do this for all the methods except init() and close().

Your final class should look like this:

Future<List<Recipe>> findAllRecipes();

Future<Recipe> findRecipeById(int id);

Future<List<Ingredient>> findAllIngredients();

Future<List<Ingredient>> findRecipeIngredients(int recipeId);

Future<int> insertRecipe(Recipe recipe);

Future<List<int>> insertIngredients(List<Ingredient> ingredients);

Future<void> deleteRecipe(Recipe recipe);

Future<void> deleteIngredient(Ingredient ingredient);

Future<void> deleteIngredients(List<Ingredient> ingredients);

Future<void> deleteRecipeIngredients(int recipeId);

Future init();

void close();

These updates allow you to have methods that work asynchronously to process data from a database or the network.

Next, add two new Streams after findAllRecipes():

// 1
Stream<List<Recipe>> watchAllRecipes();
// 2
Stream<List<Ingredient>> watchAllIngredients();

Here’s what this code does:

  1. watchAllRecipes() listens for any changes to the list of recipes. For example, if the user does a new search, it updates the list of recipes and notifies listeners accordingly.
  2. watchAllIngredients() listens for changes in the list of ingredients displayed on the Groceries screen.

You’ve now changed the interface, so you need to update the memory repository.

Cleaning Up the Repository Code

Before updating the code to use streams and futures, there are some minor housekeeping updates.

Open data/repositories/memory_repository.dart and notice there are some red squiggles. We’ll address them step by step in a bit.

First, import the Dart async library:

import 'dart:async';

Next, add these new properties within the class:

//1
late Stream<List<Recipe>> _recipeStream;
late Stream<List<Ingredient>> _ingredientStream;
// 2
final StreamController _recipeStreamController =
    StreamController<List<Recipe>>();
final StreamController _ingredientStreamController =
    StreamController<List<Ingredient>>();

Here’s what’s going on:

  1. _recipeStream and _ingredientStream are private fields for the streams. These will be captured the first time a stream is requested, which prevents new streams from being created for each call.
  2. Creates StreamControllers for recipes and ingredients.

Next, add a constructor:

MemoryRepository() {
  // 1
  _recipeStream = _recipeStreamController.stream.asBroadcastStream(
    // 2
    onListen: (subscription) {
      // 3
      // This is to send the current recipes to new subscriber
      _recipeStreamController.sink.add(state.currentRecipes);
    },
  ) as Stream<List<Recipe>>;
  _ingredientStream = _ingredientStreamController.stream.asBroadcastStream(
    onListen: (subscription) {
      // This is to send the current ingredients to new subscriber
      _ingredientStreamController.sink.add(state.currentIngredients);
    },
  ) as Stream<List<Ingredient>>;
}

This will initialize the streams.

  1. Create a broadcast stream so that multiple listeners are available.
  2. Add an onListen method to listen for new subscriptions.
  3. Send the existing recipes to the new listener.

Here, you create a broadcast stream, which you need for multiple listeners, and then update the listener with the current list of recipes when they subscribe.

And now, add these new methods after findAllRecipes():

// 3
@override
Stream<List<Recipe>> watchAllRecipes() {
  return _recipeStream;
}

// 4
@override
Stream<List<Ingredient>> watchAllIngredients() {
   return _ingredientStream;
}

The above functions are self-explanatory, watchAllRecipes() returns the stream of recipes as _recipeStream and watchAllIngredients() returns the stream of ingredients as _ingredientStream.

Updating the Existing Repository

MemoryRepository is full of red squiggles. That’s because all the methods use the old signatures, and everything’s now based on Futures.

Still in data/repositories/memory_repository.dart, replace the existing findAllRecipes() with this:

@override
// 1
Future<List<Recipe>> findAllRecipes() {
  // 2
  return Future.value(state.currentRecipes);
}

These updates:

  1. Change the method to return a Future.
  2. Wrap the return value with a Future.value().

There are a few more updates you need to make before moving on to the next section.

First, in init() remove the null from the return statement so it looks like this:

@override
Future init() {
  return Future.value();
}

For this repository, there is no initialization needed, so just an empty future is returned. Then, update close() so it closes the streams.

@override
void close() {
  _recipeStreamController.close();
  _ingredientStreamController.close();
}

When dealing with streams and their controllers, you need to make sure you close them when you are finished. Closing them in the close method makes sure those streams are closed. In the next section, you’ll update the remaining methods to return futures and add data to the stream using StreamController.

Sending Recipes Over the Stream

As you learned earlier, StreamController’s sink property adds data to streams. Since this happens in the future, you need to change the return type to Future and then update the methods to add data to the stream.

Note: Make sure you add @override above each method.

To start, change insertRecipe() to:

@override
// 1
Future<int> insertRecipe(Recipe recipe) {
  if (state.currentRecipes.contains(recipe)) {
    return Future.value(0);
  }
  // 2
  state = state.copyWith(currentRecipes: [...state.currentRecipes, recipe]);
  // 3
  _recipeStreamController.sink.add(state.currentRecipes);
  // 4
  final ingredients = <Ingredient>[];
  for (final ingredient in recipe.ingredients) {
    ingredients.add(ingredient.copyWith(recipeId: recipe.id));
  }
  insertIngredients(ingredients);
  // 5
  return Future.value(0);
}

Here’s what you’ve updated:

  1. Update the method’s return type to Future<int>.

  2. Update the state by adding the new recipe to the existing list.

  3. Add the list to the recipe sink. You might wonder why you call add() with the same list instead of adding a single ingredient or recipe. The reason is that the stream expects a list, not a single value. Doing it this way replaces the previous list with the updated one.

  4. Update all of the ingredients with the recipe ID and then insert the ingredients.

  5. Return a Future value. You’ll learn how to return the ID of the new item in a later chapter.

This replaces the previous list with the new list and notifies any stream listeners that the data has changed.

Now that you know how to convert the first method, it’s time to convert the rest of the methods as an exercise. Don’t worry, you can do it! :]

Exercise

Convert the remaining methods like you did with insertRecipe(). You’ll need to do the following:

  1. Update MemoryRepository methods to return a Future that matches the new Repository interface methods.
  2. For all methods that change a watched item, add a call to add the item to the sink.
  3. Remove all the calls to notifyListeners(). Hint - not all methods have this statement.
  4. Wrap the return values in Futures.
  5. Add @override before each method.

What do you think the return will look like for a method that returns a Future<void>? Got it? There might be a future for you yet.

return Future.value();

If you get stuck, check out memory_repository.dart in this chapter’s challenge folder — but first, give it your best shot!

After you complete the exercise, MemoryRepository shouldn’t have any more red squiggles — but you still have a few more tweaks to make before you can run your new, stream-powered app.

Note: It’s very important that you add recipes to the _recipeStreamController.sink method for recipes and _ingredientStreamController.sink for ingredients. Check the challenge project to ensure you did this correctly. You’ll need to do the same for the delete methods as well.

Switching Between Services

In an earlier chapter, you used a MockService to provide local data that never changes, but you also have access to SpoonacularService.

An easy way to do that is with an interface, or, as it’s known in Dart, an abstract class. Remember that an interface or abstract class is just a contract that implementing classes will provide the given methods.

It’ll look like this:

ServiceInterface SpoonacularService MockService

Go to the network folder and open service_interface.dart.

Here’s what it looks like:

abstract class ServiceInterface {
  /// Query recipes with the given query string
  /// offset is the starting point
  /// number is the number of items
  Future<RecipeResponse> queryRecipes(
    String query,
    int offset,
    int number,
  );

  /// Get the details of a specific recipe
  Future<Response<Result<Recipe>>> queryRecipe(
    String id,
  );
}

This defines a class with two methods. One named queryRecipes(), for a list of recipes and queryRecipe for just a single recipe.

It has the same parameters and return values as SpoonacularService and MockService. Having each service implement this interface allows you to change the providers to provide this interface instead of a specific class.

You’re now ready to integrate the new code based on streams. Fasten your seat belt! :]

Adding Streams to Bookmarks

The Bookmarks page uses Consumer, but you want to change it to a stream so it can react when a user bookmarks a recipe. To do this, you need to replace the reference to MemoryRepository with Repository and use a StreamBuilder widget.

Start by opening ui/bookmarks/bookmarks.dart. Replace // TODO: Add Recipe Stream with:

late Stream<List<Recipe>> recipeStream;

Next, replace // TODO: Add initState with:

@override
void initState() {
  super.initState();
  final repository = ref.read(repositoryProvider.notifier);
  recipeStream = repository.watchAllRecipes();
}

This will initialize the recipe stream.

Replace // TODO: Replace with Stream and the two subsequent lines with:

// 1
return StreamBuilder<List<Recipe>>(
  // 2
  stream: recipeStream,
  // 3
  builder: (context, AsyncSnapshot<List<Recipe>> snapshot) {
    // 4
    if (snapshot.connectionState == ConnectionState.active) {
      // 5
      recipes = snapshot.data ?? [];
    }

Don’t worry about the red squiggles for now. This code:

  1. Uses StreamBuilder, which uses a List<Recipe> stream type.
  2. Uses the new recipeStream to return a stream of recipes for the builder to use.
  3. Uses the builder callback to receive your snapshot.
  4. Checks the state of the connection. When the state is active, you have data.

At the bottom of the method, find // TODO: Add closing brackets and replace it with:

  },
);

At this point, you’ve achieved one of your two goals: you’ve changed the Recipes screen to use streams. Next, you’ll do the same for the Groceries tab.

Adding Streams to Groceries

To add streams to the grocery list, you’ll need to watch the ingredient stream.

Open ui/groceries/groceries.dart.

Find the initState() method and replace // TODO: Add Ingredient Stream with:

final repository = ref.read(repositoryProvider.notifier);
final ingredientStream = repository.watchAllIngredients();
ingredientStream.listen(
  (ingredients) {
    setState(() {
      currentIngredients = ingredients;
    });
  },
);

In the buildIngredientList() method, remove:

final repository = ref.watch(repositoryProvider);
currentIngredients = repository.currentIngredients;

This is no longer needed as the stream is listened to above.

Finally, modify startSearch() as follows:

void startSearch(String searchString) {
  searching = searchString.isNotEmpty;
  searchIngredients = currentIngredients
      .where((element) => true == element.name?.contains(searchString))
      .toList();
  setState(() {});
}

Stop and restart your app. Make sure it works as before. Your main screen will look something like this after a search:

Tap a recipe. The Details page will look like this:

Next, tap the Bookmark button to return to the Recipes screen, then tap on the Bookmarks switch to see the recipe you just added:

Finally, go to the Groceries tab and make sure the recipe ingredients are all showing.

Congratulations! You’re now using streams to control the flow of data. If any of the screens change, the other screens will know about that change and will update the screen.

You’re also using the Repository interface, so you can go back and forth between a memory class and a different class in the future.

Key Points

  • Streams are a way to asynchronously send data to other parts of your app.
  • You usually create streams by using StreamController.
  • Use StreamBuilder to add a stream to your UI.
  • Abstract classes, or interfaces, are a great way to abstract functionality.

Where to Go From Here?

In this chapter, you learned how to use streams. If you want to learn more about the topic, visit the Dart documentation at https://dart.dev/tutorials/language/streams.

In the next chapter, you’ll learn about databases and how to persist your data locally.

Have a technical question? Want to report a bug? You can ask questions and report bugs to the book authors in our official book forum here.
© 2026 Kodeco Inc.