Among the many state management architectures in Flutter, combining Dart streams with singleton classes (services) is an unpopular yet easy architecture.
In this article, we’ll explore how to achieve this combination for app-wide state in Flutter.
Table of Contents
What is App-wide State in Flutter?
App-wide state comprises all variables that are relevant to multiple widgets at the same time. By app-wide state, we don't mean the state that is attached to StatefulWidgets
. Those are ephemeral state. Updating them requires local or scoped calls to setState.
In Flutter, app-wide state usually has a separate logical management from UI code. This separated logic is called a state management architecture. We have many state management architectures with which we can engineer app-wide state. Examples include Provider, InheritedWidget, Riverpod, Bloc, Redux, Stacked, and so on. Each of these state management architectures are efficient, good, and opinionated.
While your choice of architecture could vary based on different factors, consider adopting the following architecture in your projects. It involves using Dart streams and services (singleton classes) for keeping track of your app's state.
What is a Stream in Dart?
A stream continuously emits values. You can listen to a stream and constantly get new values when they are emitted. Streams in Dart are the equivalent of Observable
in JavaScript.
In Dart, streams are different from futures. The difference is that while a future resolves to one value, a stream will continuously emit various values during its life.
Let's say we have a counter
stream that keeps track of some current integer count. This count could be incremented or decremented. To use the values emitted by this counter
stream, you listen to the counter
. Listening implies calling the .listen
method on the stream and handling the emitted value.
counter.listen((int value) => print('Got $value.'));
How to Create a Stream in Dart
The Stream
class comes with multiple factory constructors. They allow you to create various streams for various purposes. They include:
Stream.empty
Stream.value
Stream.error
Stream.fromFuture
Stream.fromFutures
Stream.fromIterable
Stream.multi
Stream.periodic
Stream.eventTransformed
Each constructor serves a specific purpose as its name suggests.
Another technique of creating a Stream
is by obtaining it from a StreamController
. You will have to create the StreamController
yourself. The advantage of doing this is that the controller allows you to add values to it. When you add values to the controller, they get emitted to listeners of its stream.
import 'dart:async';
void main() {
final counterCtrl = StreamController<int>();
counterCtrl.stream.listen(print);
counterCtrl.add(1); // prints 1
}
The problem with the default StreamController
from the dart:async
library is that it allows only one listener. It is unicast. If you attempt attaching another listener to this stream obtained from StreamController
, it will throw a "bad state" error.
This issue is solved by the BehaviorSubject
class from the rxdart
package. Technically, BehaviorSubject
is a StreamController
. The difference is that it has more features like:
Allows multiple listeners (very important).
Caches the latest emitted value or error.
Emits the latest cached value/error to a new listener once it newly subscribes.
Allows you to synchronously read the current (or last emitted) value from it.
Allows you to add values to it if it doesn't yet have any listener (the default
StreamController
doesn’t allow this).
The rxdart
package extends the capabilities of Dart streams. For example, it provides you with BehaviorSubject
. Also, it exposes classes and extensions that allow more stream manipulations. To use the rxdart
package, add it to your project's dependencies from pub using the following command:
flutter pub add rxdart
Then import it in your project's Dart files. From there, you can create BehaviorSubject
(more robust StreamController
) that can allow multiple listeners while allowing you to control them (adding values to the streams).
import 'package:rxdart/rxdart.dart';
void main() {
// Create a BehaviorSubject.
//
// Asides from creating the BehaviorSubject, we can also
// immediately add a value to it using Dart's cascade operator.
final counterBS = BehaviorSubject<int>()..add(0);
counterBS.stream.listen(print); // prints 0
counterBS.stream.listen(print); // prints 0
counterBS.add(1); // prints 1 twice
}
Now that we can create streams (and listen to them), we need the exact same streams to be available to every part of our Flutter apps.
To ensure that it is the same instance of streams that different parts of our Flutter apps are accessing, we can expose the streams from singleton class instances that we create in the project.
How to Create Singleton Class Instances (or Services)
When something is called a singleton, it means only one of it exists. For example, we can say the sun is a singleton star because we have only one sun.
When it comes to programming, we use a singleton when we need the same copy of an object everywhere. Already, the static
properties of a class are singletons to every instance of that class. When you declare a field or method as static
, you're telling the runtime engine to always reuse the same static item.
This explains why static
properties are used as constants. It's another reason why we use them without instantiating an object. Furthermore, in Flutter, we conventionally use static properties as a means to obtain new or existing instances of a class. For example, many Flutter classes (MediaQuery
, Navigator
, ThemeData
, and so on) have a static .of
method for obtaining their instances.
In this streams and services architecture, we expose only one instance from a class with the static
keyword. At the same time, we hide that class constructor. Hiding the constructor ensures that no other Dart code outside the Dart file can create another instance of the same class. Doing this maintains the instance as a singleton.
Following common conventions, we can call this class a service. Any other Dart file in the project can listen to the exposed stream(s) from the service class and always get updated values emitted to it.
Services here are holders of app-wide state. Each service is a logical container of related features. In any other part of the code, through these services, we can access app-wide state variables (in our case, streams). In a production application, we could have an authentication service, another for notifications, another for files, and so on.
To have an app-wide available service (singleton class) with a stream in it:
Create a service class.
Create a private constructor (so that no other Dart code outside the class can instantiate it).
Create a static private instance of that very class.
Expose this private instance as the singleton.
Create a private
BehaviorSubject
in that class.Expose the
BehaviorSubject
stream as a public static getter from the class.
/* In counter_service.dart file */
import 'package:rxdart/rxdart.dart';
// 1. Create a class
//
// The class name with "Service" appended to it indicates
// that it is an app-wide state object.
class CounterService {
// 2. Create a private constructor.
//
// This "just-underscore" constructor works. If we want, we could
// still add a name after the underscore. The main thing is that
// underscore makes the constructor to be a private one.
CounterService._();
// 3. Create a static private instance.
//
// Prefixing underscore (_) to the variable name makes it private.
// By being private, no other Dart code outside this file can directly
// access it.
static final _instance = CounterService._();
// 4. Expose this private instance as the singleton.
static CounterService get instance => _instance;
// 5. Create a private BehaviorSubject.
final _counterBS = BehaviorSubject<int>()..add(0);
// 6. Expose the BehaviorSubject's Stream.
Stream<int> get countStream => _counterBS.stream;
// Also, if need be, expose the BehaviorSubject's current as a getter.
int get currentCount => _counterBS.value;
}
/* In any other Dart file in the project */
import 'counter_service.dart'
// Attach a listener to the stream
CounterService.instance.countStream.listen((count) {
// Use the count as use wish. Code you write within this
// listener's block will be called whenever count is
// update/re-emitted.
print(count); // prints 0
});
// Read the current stream value just once without subscribing
print(CounterService.instance.currentCount); // prints 0
How to Manipulate State (Streams) Within Services
Most times, each service will have multiple streams. This is as expected, given that, for a given logical state feature, there would be multiple variables affecting it. Therefore, where need be, don't hesitate to declare multiple BehaviorSubject
(while exposing their streams) within the same service class.
For each stream, you want to control its data. That's why we are using BehaviorSubject
, so that we can add values to it when there is a need to update state.
Different events (whether from the user or your servers) can be the cause of such state updates. You want to trigger state updates (or add values to streams) anytime those events occur.
You could always poll your backend and emit changes to your streams if any event happens. You could also emit values based on changes in other services. In addition, if need be, services should also expose relevant methods that will update their streams. In turn, other parts of the app can call these methods and trigger changes. The obvious advantage is that every listener will respectively get the new stream value emitted to them.
/* In counter_service.dart file */
import 'package:rxdart/rxdart.dart';
class CounterService {
CounterService._();
static final _instance = CounterService._();
static CounterService get instance => _instance;
final _counterBS = BehaviorSubject<int>()..add(0);
Stream<int> get countStream => _counterBS.stream;
int get currentCount => _counterBS.value;
// Incrementing/Decrementing the counter will trigger state updates.
void incrementCount() => _counterBS.add(currentCount + 1);
void decrementCount() => _counterBS.add(currentCount - 1);
}
/* In another Dart file in the project */
import 'counter_service.dart'
void main() {
final service = CounterService.instance;
service.countStream.listen(print); // prints 0
service.incrementCount(); // causes 1 to be printed
service.decrementCount(); // causes 0 to be printed
}
For a more concrete example, let's say we have an AuthenticationService
. It declares some _userBS
and exposes a currentUser
stream with type Stream<User?>
, the user will be valid if authenticated or null
if signed out. This auth service will naturally have signIn
and signOut
which can both add values to _userBS
. The sign-up and login screens can each call signIn
whereas the “switch account” and “log out” buttons can each call signOut
.
/* In user.dart */
// A simple user with only email and username for demo purposes.
// Your User model/schema would have more properties.
class User {
final String email;
final String username;
const User(this.email, this.username);
}
/* In authentication_service.dart */
import 'package:rxdart/rxdart.dart';
import 'user.dart';
class AuthenticationService {
AuthenticationService._();
static final _instance = AuthenticationService._();
static AuthenticationService instance => _instance;
// User BehaviorSubject and its stream.
final _userBS = BehaviorSubject<User?>()..add(null);
Stream<User?> get currentUser => _userBS.stream;
// signIn adds a new User to the stream.
void signIn(String email, String username}) {
_userBS.add(User(email, username));
}
// signOut sets the currentUser as null
void signOut() => _userBS.add(null);
// signIn and signOut methods that tamper the state could do other
// actions like recording analytics or carrying out navigation.
// Also, they could do some validation or run some checks before
// emitting values. The idea is that you get comfortable with
// updating the values of BehaviorSubject (hence emitting streams)
// from controlled methods within the service.
}
Another state manipulation point is at initializing services. Some streams may warrant an asynchronous initializer before they should be used. You can define init
methods in the services, and call the methods before calling runApp
in the topmost main method in Flutter.
init
methods may be "localStorage"-saved values from previous app runs. They can make API calls, check permissions, or set up EventChannel listeners. When you call them before runApp
, be sure to call ensureInitialized()
from WidgetsFlutterBinding
before initializing the services. This is especially mandatory if any of the service init
code will access a PlatformChannel
.
/* authentication_service.dart */
// ... imports
class AuthenticationService {
// ... other code
// initialize the service and carry-out other setups if need be.
Future<void> init() async => _userBS.add(await _fetchSavedUser());
}
/* main.dart */
import 'package:flutter/material.dart';
import 'authentication_service.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize the service to be sure it is up and running before
// launching the app. You could also initialize other services here.
// Only do this if they are carrying out asynchronous executions,
// and the results need to be ready before the UI launches.
await AuthenticationService.instance.init();
runApp(const MyApp());
}
How to Use Dart Streams in Flutter Widgets
Flutter comes with a built-in StreamBuilder widget. It takes a stream and a builder function. This builder function will get a BuildContext
and snapshot data about the stream. The function should always return a widget.
When building UIs, you can wrap UI parts that depend on or display values emitted from app-wide streams in StreamBuilders
. That way, once the stream emits a value, Flutter auto-rebuilds the children widget of the StreamBuilders
with the latest values.
import 'package:flutter/material.dart';
import 'counter_service.dart';
class CounterWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
return StreamBuilder<int>(
stream: CounterService.instance.countStream, // The stream to listen to
initialData: CounterService.instance.currentCount, // Initial value
builder: (context, snapshot) {
// Check if the snapshot has data
if (snapshot.hasData) {
return Text('Counter: ${snapshot.data}', style: TextStyle(fontSize: 24));
} else {
// Handle any error or empty state
return Text('Loading...', style: TextStyle(fontSize: 24));
}
},
);
}
}
StreamBuilders
are great tools. However, there are times when it is not suitable to use them. For example:
When a given UI screen depends on multiple streams that are exposed by the same or different services.
When you want to do some computation on the stream values before rendering them in the UI.
In those cases, we need to listen to the streams separately in initState
, set values through setState
calls (to update the UI), and dispose of the StreamSubscriptions
in the StatefulWidget's dispose
method.
Listening to the streams separately allows us to perform any customizations or to merge data when the streams emit values. In addition, we make our UI code easier to read given that we’ve taken out logic-related code from the build method. However, we should do this only when necessary: StreamBuilders
will, most of the time, be sufficient.
import 'dart:async';
import 'package:flutter/material.dart';
import 'counter_service.dart';
class CounterStatefulWidget extends StatefulWidget {
const CounterStatefulWidget({super.key});
@override
_CounterStatefulWidgetState createState() => _CounterStatefulWidgetState();
}
class _CounterStatefulWidgetState extends State<CounterStatefulWidget> {
late StreamSubscription<int> counterSub;
int count = CounterService.instance.currentCount;
@override
void initState() {
super.initState();
// Initialize the stream subscription
counterSub = CounterService.instance.countStream.listen((count) {
// Update state on new stream value
setState(() => this.count = count);
});
}
@override
void dispose() {
// Dispose of the stream subscription to avoid memory leaks
counterSub.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Text('Counter: $count', style: TextStyle(fontSize: 24));
}
}
The example above demonstrates listening and disposing from outside the build method. The example is not a good use case of when you should do that.
How to Make a Service Depend on Another
In complex applications, it's common to have services that depend on each other. The dependent service can listen to streams and call methods of the independent service. Also, the dependent service can import and reference the independent service just as we’ve been doing in UI code above.
For instance, if we are building an e-commerce app, a CartService
may depend on an AuthenticationService
to fetch carts and orders for the signed-in user. If the user signs out, some currentUser
stream in the AuthenticationService
will emit null
. In turn, the listening CartService
will update the cart. When next a new user signs in, it will fetch the new cart.
import 'package:rxdart/rxdart.dart';
import 'authentication_service.dart';
// Item model representing a cart item.
class CartItem {
final String name;
final int quantity;
const CartItem(this.name, this.quantity);
}
// CartService to manage the user's shopping cart.
class CartService {
// ...
// Dependency on AuthenticationService.
final _auth = AuthenticationService.instance;
final _cartItemsBS = BehaviorSubject<List<CartItem>>();
Stream<List<CartItem>> get cartStream => _cartItemsBS.stream;
CartService() {
// Listen to the currentUser stream in AuthenticationService.
_auth.currentUserStream.listen((user) {
if (user == null) {
// User signed out, clear the cart.
_clearCart();
} else {
// User signed in, fetch their cart.
_fetchCartForUser(user.email);
}
});
}
// Method to clear the cart (called on sign-out).
void _clearCart() {
_cartItemsBS.add([]); // Emit an empty list to clear the cart.
}
// Method to fetch the cart for a signed-in user (simulated).
Future<void> _fetchCartForUser(String email) async {
// ...
}
}
Watch out for circular dependency problems when your services depend on each other. Circular dependency occurs when two services inter-depend on themselves. This scenario is usually inevitable as business logic grows.
When faced with it, lift the state they want to co-share to a different service and import this new service into the others. Another solution is to use Dart’s late
keywords when importing the interdependent services. You can also find ways to ensure that variable accessing is within functions and not at some top-level declaration.
How to Improve Streams with rxdart Classes and Extensions
Asides from having service methods that update streams, you can also have new or improved streams based on existing ones, by using rxdart
classes and extensions.
An example class is CombineLatestStream
. It takes multiple streams and a combiner function to return a new stream that will re-emit the combined latest values of the source streams (depending on the optional combiner).
import 'package:rxdart/rxdart.dart';
class MultipliedCounterService {
// ...
final _counterBS = BehaviorSubject<int>()..add(0);
final _multiplierBS = BehaviorSubject<int>()..add(2);
Stream<int> get combinedStream => CombineLatestStream(
[_counterBS.stream, _multiplierBS.stream],
(values) => values[0] * values[1],
);
void incrementCounter() => _counterBS.add(_counterBS.value + 1);
void changeMultiplier(int mul) => _multiplierBS.add(mul);
}
Another good stream method is debounceTime
. This is a stream extension that is useful for ignoring frequent emissions and processing the latest value after a delay (like when searching). An emission will only occur after the set duration and when there is no other emission in between that time. It helps avoid excessive API calls by waiting for a period of inactivity before emitting the latest value.
import 'package:rxdart/rxdart.dart';
class SearchService {
// ...
final _searchQueryBS = BehaviorSubject<String>()..add('');
// Stream with debouncing to emit values only after a
// 300ms delay. For example: keystrokes will be bundled at once.
Stream<String> get debouncedSearchQueryStream =>
_searchQueryBS.stream.debounceTime(Duration(milliseconds: 300));
void updateSearchQuery(String query) => _searchQueryBS.add(query);
}
The rxdart
package provides more classes and stream extensions that will be useful to you, even if you don’t use this architecture. Check them out later on.
How to Update State in AppLifecycle Callbacks
When a user minimizes or leaves your application and comes back, some external things you rely on for data may have changed.
For example, when you prompt a user to grant any permissions, the operating system displays a popup over your application. Programmatically, the displayed popup caused your app to lose focus or go into background mode. When the popup is gone, your app resumes focus and you need to detect whether you got the permissions.
Equally, if you are managing the contents of a specific File Explorer Directory within your application (like converted music, encrypted docs, call logs, and so on), when your app goes in background, there could be changes to that directory from the user, which are worth detecting when the user comes back.
Sometimes, you may want to know when the user comes back to your application for authentication purposes, like terminating a session if they stayed away for too long and they need to re-authenticate. Other times, you may want to refresh app contents, to retain the user, as you can do if building a social media app.
In all these cases, we need a way to programmatically know when our app comes back to the user's focus after the user had left. Luckily, Flutter provides us with AppLifecycleState
and a way to react to changes to them.
An app’s lifecycle refers to its various states while it is running. In Flutter, AppLifecycleState
includes detached, resumed, inactive, hidden, and paused. In the above example cases, anytime the user comes back to the app, the app’s lifecycle state becomes AppLifecycleState.resumed
.
We can react to lifecycle changes and call our service methods when a particular state occurs. To listen to lifecycle changes, your service class should add the WidgetsBindingObserver
mixin to its declaration. Then you should override didChangeAppLifecycleState
with a callback. This callback should handle states it is interested in.
import 'package:flutter/material.dart';
class PermissionService with WidgetsBindingObserver {
// ...
Future<void> checkPermissions() async {
// ...
}
@override
Future<void> didChangeAppLifecycleState(AppLifecycleState state) async {
if (state == AppLifecycleState.resumed) {
await checkPermissions();
}
// you can check for the other states too and handle as expected.
}
}
Flexibility in State Management
There are multiple choices and flavors for state management in the Flutter community. Most of the time, the same features can always be built with any state management of choice.
With that in mind, be flexible with state management architectures in Flutter. They are not some hard cast rules. Bend and play around with them to suit your unique app cases as there is no "one size fits all" here.
You can play around with streams and services. You could use getIt for obtaining singletons. getIt
also allows you to obtain scoped singletons, that is, singletons attached to a navigator or a logical part of features (within a search for example).
You can also combine this architecture with others. Like declaring and managing streams as explained here but in providers or cubits. Or bringing in features of other architectures into services you declare as described in this article.
Just be sure you know what you're doing and that you understand how to coordinate the variables representing app state. Preferably, document your choice of architectures in your codebase for future reference.
Summary
In summary, we have explored an efficient architecture for managing app-wide state in Flutter using Dart streams and singleton services.
We've also seen how to manipulate streams, how to use them in UI code, make services depend on each other, improve streams using rxdart
, and handle app lifecycle changes.
Remember that state management in Flutter is flexible, and no one solution fits all. Tailor your choice of state management architecture to fit your specific app needs.