Synchronizing state with observers in Dart and Flutter

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.

  1. The Traditional Approach
  2. Extended Observer Pattern
    1. Internet Connectivity Observer
    2. Log Observer
  3. Conclusion

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:

  1. Need to manually track previous values
  2. Difficult to maintain as the number of fields grows
  3. 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.