Flutter Riverpod: How to Register a Listener during App Startup

Have you ever needed to register a listener as soon as the app starts?

Examples of this include:

In all these cases, our goal is to:

  • register a stream listener to handle all incoming events
  • run some code to modify the application state or navigate to a specific page

For example, you may have written code like this to process all incoming links from FirebaseDynamicLinks:

void main() async { // Normal initialization WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); // register a listener to process incoming links FirebaseDynamicLinks.instance.onLink.listen((link) { // TODO: Handle link }); // run the app runApp(const MyApp()); }

While this works, it can get out of hand if the event handling code is complex.

And if you need more than one listener, your main() method quickly becomes a dumping ground for all your app startup code.

Luckily, the Riverpod package can help us here, and we can use it to:

  • initialize complex objects that have one or more dependencies (by reading the corresponding providers)
  • keep our app startup logic neat and tidy

Along the way, we'll learn about useful classes such as ProviderContainer and UncontrolledProviderScope, and add a new valuable technique to our developer toolkit. 🛠

Ready? Let's go!

Revisiting the App Initialization Logic

As a starting point, let's consider this code from the example above:

void main() async { ... // register a listener to process incoming links FirebaseDynamicLinks.instance.onLink.listen((link) { // TODO: Handle link }); runApp(const MyApp()); }

Putting this inside the main method is not ideal, especially if the event handling logic is complex and we need to access additional dependencies. And we should also avoid using the FirebaseDynamicLinks.instance singleton here.

Accessing singletons directly in our code has various drawbacks, and there are better alternatives. For more info, read: Singletons in Flutter: How to Avoid Them and What to do Instead.

A better approach is to:

  • move all the listener and event handling logic into a separate class
  • initialize that class when the app starts

So let's see how to do it with the Riverpod package, following a 4-step process.

1. Create a StreamProvider

First of all, let's create a StreamProvider that gives us access to the stream we need:

final onDynamicLinkProvider = StreamProvider<PendingDynamicLinkData>((ref) { // For simplicity, here we use FirebaseDynamicLinks directly. // On production codebases we would get the stream from a DynamicLinksRepository. return FirebaseDynamicLinks.instance.onLink; });

For simplicity, we're accessing FirebaseDynamicLinks.instance directly inside the provider. But on a production codebase, we may create a DynamicLinksRepository that takes the FirebaseDynamicLinks as a constructor argument instead. For more details, read my article about the repository pattern.

2. Create a Service Class with the Event Handling Code

Now that we have our onDynamicLinkProvider, we can create a service class that uses it:

class DynamicLinksService { // 1. Pass a Ref argument to the constructor DynamicLinksService(this.ref) { // 2. Call _init as soon as the object is created _init(); } final Ref ref; void _init() { // 3. listen to the StreamProvider ref.listen<AsyncValue<PendingDynamicLinkData>>(onDynamicLinkProvider, (previous, next) { // 4. Implement the event handling code final linkData = next.value; if (linkData != null) { debugPrint(linkData.toString()); // TODO: Handle linkData } }); } }

A few notes:

  1. The DynamicLinksService class takes a Ref argument that we can use to access any provider we may need.
  2. We call the private _init method immediately inside the constructor.
  3. We register a listener to our stream using ref.listen.
  4. Inside the listener callback, we can process the previous and next values as needed.

The type of the previous and next values above is AsyncValue<PendingDynamicLinkData>, because listening or watching a Stream<T> always gives us values of type AsyncValue<T>. If you're not familiar with AsyncValue, read: Flutter Riverpod Tip: Use AsyncValue rather than FutureBuilder or StreamBuilder.

I should also point out that:

  • The _init method is private, and any other methods you add to this class should be private too. This way, the only one way to start the listener is by creating an instance of DynamicLinksService.
  • I haven't included the event handling code since this is application-specific. If you need to access any other dependencies in this class, you can call ref.read(someProvider).someMethod().

3. Create a Provider for the Service Class

Once we have our DynamicLinksService class, we can create a provider that we'll use to access it:

final dynamicLinksServiceProvider = Provider<DynamicLinksService>((ref) { return DynamicLinksService(ref); });

This is very simple as we just need to pass the ref argument to the constructor.

But if we start the app now, the code inside the DynamicLinksService will not run because the dynamicLinksServiceProvider will only create it when we first read it (Riverpod providers are lazy-loaded), and no widgets or other classes are using it.

In other words: if we want to use the DynamicLinksService, we need to initialize it inside the main() method.

4. Read the Service Class Provider with ProviderContainer

Here's our main() method once again:

void main() async { // Normal initialization WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); // TODO: How to initialize our DynamicLinksService? // run the app runApp(ProviderScope( child: const MyApp(), )); }

Note that we can only create a DynamicLinksService with a Ref object, and the main() method doesn't have one. 🧐

To solve this chicken-and-egg problem 🐣, we need to use a ProviderContainer. Here's how:

void main() async { // Normal initialization WidgetsFlutterBinding.ensureInitialized(); await Firebase.initializeApp( options: DefaultFirebaseOptions.currentPlatform, ); // 1. Create a ProviderContainer final container = ProviderContainer(); // 2. Use it to read the provider container.read(dynamicLinksServiceProvider); // 3. Pass the container to an UncontrolledProviderScope and run the app runApp(UncontrolledProviderScope( container: container, child: const MyApp(), )); }

And voila! We can now read the dynamicLinksServiceProvider with a ProviderContainer that is then passed as an argument to an UncontrolledProviderScope.

And since the provider will create the DynamicLinksService, our listener will now be registered and handle all incoming events when the app starts! 🏁


By the way, note that the call to container.read() returns the DynamicLinksService itself:

final dynamicLinksService = container.read(dynamicLinksServiceProvider);

But in this case, we can ignore return value as we don't need it. Besides, we can't call any methods on it as the only public method is the constructor (which starts the listener).

How do ProviderContainer and UncontrolledProviderScope work?

But what is a ProviderContainer? The Riverpod documentation defines it as:

An object that stores the state of the providers and allows overriding the behavior of a specific provider.

It also says this:

If you are using Flutter, you do not need to care about this object (outside of testing), as it is implicitly created for you by ProviderScope.

The exception to this rule is if we need to create an object that takes a Ref argument inside the main() method. In this case, creating a ProviderContainer explicitly gives us an "escape hatch" and lets us access/initialize the DynamicLinksService.

And if you had any provider overrides in the ProviderScope, you can now move them to the ProviderContainer:

final container = ProviderContainer( overrides: [], // list your overrides here );

Conclusion

We've now figured out how to register a listener during app startup while keeping our main() method nice and tidy.

Once again, these are the four steps:

  1. Create a StreamProvider
  2. Create a service class with a listener that handles all stream events
  3. Create a provider for the service class
  4. Read the service class provider with ProviderContainer inside main()

This approach scales well if you have multiple service classes, because you can initialize each service with a single line of code:

final container = ProviderContainer(); container.read(authServiceProvider); container.read(dynamicLinksServiceProvider); container.read(messagingServiceProvider); runApp(UncontrolledProviderScope( container: container, child: const MyApp(), ));

This is most useful for listeners that are:

  • always active while the app is running
  • independent from the UI and not specific to any given widget

Though if you wish, you can add an output Stream or ValueListenable to your service class so that widgets in the UI layer can observe it. This is useful if you want to show an alert or SnackBar, or navigate to a specific page when a certain event takes place.


That's it! We now have a repeatable process for registering listeners in a scalable way without overloading the main method with all the initialization logic. 🚀

And for more info about how to write unit tests for service classes that take a Ref argument and use it to access dependencies, you can check out my Flutter course. 👇

New Flutter Course Now Available

I launched a brand new course that covers automated testing in great depth, along with other important topics like state management with Riverpod, app architecture, navigation, and much more:

Want More?

Invest in yourself with my high-quality Flutter courses.

The Complete Dart Developer Guide

The Complete Dart Developer Guide

Learn Dart Programming in depth. Includes: basic to advanced topics, exercises, and projects. Fully updated to Dart 2.15.

Flutter Animations Masterclass - Full Course

Flutter Animations Masterclass - Full Course

Master Flutter animations and build a completely custom habit tracking application.