Synchronizing state with observers in Dart and Flutter
Flutter's Listenable interface and ChangeNotifier mixin provide a foundational approach to state management, allowing objects to notify listeners when their internal state changes. While powerful, this basic pattern can lead to verbose and hard-to-maintain code in more complex applications. This article introduces an enhanced version of the observer pattern that addresses these limitations.
The Traditional Approach
Let's start with a typical implementation using ChangeNotifier. Consider a simple app settings manager:
class SimpleAppSettings with ChangeNotifier {
bool _notificationsEnabled = false;
String _someAuthenticationToken = '';
bool get notificationsEnabled => _notificationsEnabled;
String get someAuthenticationToken => _someAuthenticationToken;
void setNotificationsEnabled(bool enabled) {
_notificationsEnabled = enabled;
notifyListeners();
}
void setSomeAuthenticationToken(String token) {
_someAuthenticationToken = token;
notifyListeners();
}
}To react to changes in these settings, we typically create a widget that listens to the settings object and manually compares previous values:
class SettingsListener extends StatefulWidget {
const SettingsListener({
required this.child,
super.key,
});
final Widget child;
@override
State<SettingsListener> createState() => _SettingsListenerState();
}
class _SettingsListenerState extends State<SettingsListener> {
var _token = '';
var _notificationsEnabled = false;
@override
void initState() {
super.initState();
settings.addListener(_onSettingsChanged);
}
@override
void dispose() {
settings.removeListener(_onSettingsChanged);
super.dispose();
}
void _onSettingsChanged() {
// if token changed show snackbar
if (_token != settings.someAuthenticationToken) {
_token = settings.someAuthenticationToken;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Token changed to ${settings.someAuthenticationToken}'),
),
);
}
if (_notificationsEnabled != settings.notificationsEnabled) {
_notificationsEnabled = settings.notificationsEnabled;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Notifications enabled: ${settings.notificationsEnabled}'),
),
);
}
}
@override
Widget build(BuildContext context) => widget.child;
}
This approach has several drawbacks:
- Need to manually track previous values
- Difficult to maintain as the number of fields grows
- Easy to introduce bugs in the comparison logic
Extended Observer Pattern
Let's introduce a more elegant solution using dedicated observers for specific changes:
/// A mixin that provides a convenient way to listen to app settings changes.
mixin AppSettingsObserver {
/// Called when app settings are changed.
void onAppSettingsChanged() {}
/// Called when notifications are changed.
void onNotificationsChanged() {}
/// Called when authentication token is changed.
void onAuthenticationTokenChanged) {}
}
class ExtendedAppSettings {
final _observers = <AppSettingsObserver>{};
bool _notificationsEnabled = false;
String _someAuthenticationToken = '';
bool get notificationsEnabled => _notificationsEnabled;
String get someAuthenticationToken => _someAuthenticationToken;
void addObserver(AppSettingsObserver observer) {
_observers.add(observer);
}
void removeObserver(AppSettingsObserver observer) {
_observers.remove(observer);
}
void setNotificationsEnabled(bool enabled) {
_notificationsEnabled = enabled;
for (final observer in _observers) {
observer.onNotificationsChanged();
observer.onAppSettingsChanged();
}
}
void setSomeAuthenticationToken(String token) {
_someAuthenticationToken = token;
for (final observer in _observers) {
observer.onAuthenticationTokenChanged();
observer.onAppSettingsChanged();
}
}
}The idea is to introduce separate notification channels for specific events. Now our widget can listen to specific changes without manual tracking:
class SettingsListener extends StatefulWidget {
const SettingsListener({
required this.child,
super.key,
});
final Widget child;
@override
State<SettingsListener> createState() => _SettingsListenerState();
}
class _SettingsListenerState extends State<SettingsListener> with AppSettingsObserver {
@override
void initState() {
super.initState();
settings.addObserver(this);
}
@override
void dispose() {
settings.removeObserver(this);
super.dispose();
}
@override
void onNotificationsChanged() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Notifications enabled: ${settings.notificationsEnabled}'),
),
);
}
@override
void onAuthenticationTokenChanged() {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('Token changed to ${settings.someAuthenticationToken}'),
),
);
}
@override
Widget build(BuildContext context) => widget.child;
}Internet Connectivity Observer
Monitoring internet connectivity is a common requirement that benefits from this pattern:
/// A mixin class that provides internet access status.
mixin InternetAccessObserver {
/// This method is called when the internet access status changes.
void onInternetAccessChanged({required bool hasAccessToInternet}) {}
}If you have only one field (e.g., hasAccessToInternet), you still need to compare the previous and current values. However, with a dedicated method, this comparison is no longer necessary:
@override
void onInternetAccessChanged({required bool hasAccessToInternet}) {
final bloc = DependenciesScope.of(context).imagineConnectionBloc;
if (hasAccessToInternet && bloc.state.isDisconnected && !bloc.state.isConnecting) {
bloc.add(const GenerateConnectionEventConnect());
return;
}
if (!hasAccessToInternet && bloc.state.isConnected) {
bloc.add(const GenerateConnectionEventDisconnect());
return;
}
}Log Observer
Another common scenario is listening for log messages from a logger. This can serve various purposes, such as reporting error messages to services like Sentry or Firebase Crashlytics:
/// {@template error_reporter_log_observer}
/// An observer that reports logs to the error reporter if it is active.
/// {@endtemplate}
final class ErrorReporterLogObserver with LogObserver {
/// {@macro error_reporter_log_observer}
const ErrorReporterLogObserver(this._errorReporter);
/// Error reporter used to report errors.
final ErrorReporter _errorReporter;
@override
void onLog(LogMessage logMessage) {
// If the error reporter is not initialized, do nothing
if (!_errorReporter.isInitialized) return;
// If the log level is error or higher, report the error
if (logMessage.level.index >= LogLevel.error.index) {
_errorReporter.captureException(
throwable: logMessage.error ?? ReportedMessageException(logMessage.message),
stackTrace: logMessage.stackTrace ?? StackTrace.current,
);
}
}
}Or simply printing them to the console in your preferred format:
/// {@template printing_log_observer}
/// [LogObserver] that prints logs using `dart:developer`.
/// {@endtemplate}
final class PrintingLogObserver with LogObserver {
/// {@macro printing_log_observer}
const PrintingLogObserver({required this.logLevel});
/// The log level to observe.
final LogLevel logLevel;
@override
void onLog(LogMessage logMessage) {
if (logMessage.level.index >= logLevel.index) {
final logLevelsLength = LogLevel.values.length;
final severityPerLevel = 2000 ~/ logLevelsLength;
final level = logMessage.level.index * severityPerLevel;
dev.log(
logMessage.message,
time: logMessage.timestamp,
error: logMessage.error,
stackTrace: logMessage.stackTrace,
level: level,
name: logMessage.level.toShortName(),
);
}
}
}Conclusion
The extended observer pattern offers neat advantages over the traditional ChangeNotifier approach:
- Eliminates the need for manual state comparison
- Provides type-safe, clearly defined interfaces
- Reduces boilerplate code
- Improves code organization and maintainability
- Enables granular control over change notifications
While it requires slightly more initial setup, the benefits in code clarity, maintainability, and reduced potential for bugs make it a good choice.