state_notifier 0.4.0

  • Readme
  • Changelog
  • Example
  • Installing
  • new89

pub package Welcome to state_notifier~

This repository is a set of packages that reimplements ValueNotifier outside of Flutter.

It is spread across two packages:

  • state_notifier, a pure Dart package containing the reimplementation of ValueNotifier.
    It comes with extra utilities for combining our "ValueNotifier" with provider and to test it.
  • flutter_notifier, a binding between state_notifier and Flutter.
    It adds things like ChangeNotifierProvider from provider, but compatible with state_notifier.

Motivation #

Extracting ValueNotifier outside of Flutter in a separate package has two purposes:

  • It allows packages Dart packages with no dependency on Flutter to use these classes.
    This means that we can use them on AngularDart for example.
  • It allows solving some common problems with the original ChangeNotifier/ValueNotifier and/or their combination with provider.

For example, by using state_notifier instead of the original ValueNotifier, then you get:

  • A significant simplification of the integration with provider
  • Simplified testing/mocking
  • Improved performances on addListener & notifyListeners equivalents.
  • Extra safety through small API changes

Integration with provider/service locators #

StateNotifier is easily compatible with provider through an extra mixin: LocatorMixin.

Consider a typical StateNotifier written like a ValueNotifier:

class Counter extends StateNotifier<int> {
  Counter(): super(0)

  void increment() {
    state++;
  }
}

In this example, we may want to use Provider.of/context.read to connect our Counter with external services.

To do so, simply mix-in LocatorMixin as such:

class Counter extends StateNotifier<int> with LocatorMixin {
// unchanged
}

This then gives you access to:

  • read, a function to obtain services
  • update, a new life-cycle that can be used to listen to changes on a service

We could use them to change our Counter incrementation to save the counter in a DB when incrementing the value:

class Counter extends StateNotifier<int> {
  Counter(): super(0)

  void increment() {
    state++;
    read<LocalStorage>().writeInt('count', state);
  }
}

Testing

When using LocatorMixin, you may want to mock a dependency for your tests.
Of course, we still don't want to depend on Flutter/provider to do such a thing.

Similarly, since state is protected, tests need a simple way to read the state.

As such, LocatorMixin also adds extra utilities to help you with this scenario:

myStateNotifier.debugMockDependency<MyDependency>(myDependency);
print(myStateNotifier.debugState);
myStateNotifier.debugUpdate();

As such, if we want to test our previous Counter, we could mock LocalStorage this way:

test('increment and saves to local storage', () {
  final mockLocalStorage = MockLocalStorage();
  final counter = Counter()
    ..debugMockDependency<LocalStorage>(mockLocalStorage);

  expect(counter.debugState, 0);

  counter.increment(); // works fine since we mocked the LocalStorage

  expect(counter.debugState, 1);
  // mockito stuff
  verify(mockLocalStorage.writeInt('int', 1));
});

Differences with ValueNotifier #

This is not a one-to-one reimplementation of ValueNotifier. It has some differences:

  • ValueNotifier is instead named StateNotifier (to avoid name clash)
  • ValueNotifier.value is renamed to state, to match the class name
  • StateNotifier is abstract
  • state is @protected
  • The listener passed to addListener receives the current state, and is called synchronously on addition.
  • addListener and removeListener are fused in a single addListener function which returns a function to remove the listener.
    This makes adding and removing listeners O(1) versus O(N) for ValueNotifier.
  • listeners cannot add extra listeners.
    This makes notifying listeners O(N) versus O(N²) for ValueNotifier
  • offers a mounted boolean to know if the StateNotifier was disposed or not, similar to State.

[0.4.0] - 13/03/2020 #

Added a flag on addListener to disable the first immediate call of the listener thanks to @smiLLe

[0.3.0] - 08/03/2020 #

Add an initState life-cycle to be able to init the StateNotifier using read.

[0.1.0+1] - 06/03/2020 #

Add example

[0.1.0] - 06/03/2020 #

Initial release.

example/lib/state_notifier.dart

library state_notifier;

import 'dart:collection';

import 'package:meta/meta.dart';

/// A listener that can be added to a [StateNotifier] using
/// [StateNotifier.addListener].
///
/// This callback receives the current [StateNotifier.state] as parameter.
typedef Listener<T> = void Function(T state);

/// A callback that can be used to remove a listener added with
/// [StateNotifier.addListener].
///
/// It is safe to call this callback multiple times.
///
/// Calling this callback multiple times will remove the listener only once,
/// even if [StateNotifier.addListener] was called multiple times with the exact
/// same listener.
typedef RemoveListener = void Function();

/// A callback that can be passed to [StateNotifier.onError].
///
/// This callback should not throw.
///
/// It exists merely for error reporting (mainly `FlutterError.onError`), and
/// should not be used otherwise.\
/// If you need an error status, consider adding an error property on
/// your custom [StateNotifier.state].
typedef ErrorListener = void Function(dynamic error, StackTrace stackTrace);

/// A function that allows obtaining other objects.
///
/// This is usually equivalent to `Provider.of`, but with no dependency on Flutter
///
/// May throw a [DependencyNotFoundException].
typedef Locator = T Function<T>();

/// An observable class that stores a single immutable [state].
///
/// This class can be considered as a fork of `ValueNotifier` from Flutter, with
/// subtle API changes.
///
/// # Reading the current [state]:
///
/// The [state] property is protected and should be used only by the subclasses
/// of [StateNotifier].\
/// If you want to obtain the current [state] from outside of [StateNotifier],
/// consider using [addListener] instead.
///
/// All listeners added with [addListener] are called **immediately** on addition
/// with the current [state] as parameter, or whenever [state] changes.
///
/// # The differences with `ValueNotifier`
///
/// [StateNotifier] is not a one to one reimplementation of `ValueNotifier`.
/// It has a few notable differences:
///
/// - it does not depend on Flutter
/// - both the getter and setter of [state] are protected.
/// - adding and removing listeners is O(1) (vs O(N) for `ValueNotifier`)
/// - there is no `notifyListeners` method.
/// - listeners cannot add more listeners.
/// - calling all listeners is O(N) (vs O(N^2) for `ValueNotifier`)
/// - there is no `removeListener` function.
///   Instead, [addListener] returns a function that allows removing the listener.
/// - error reporting is done through [onError].
/// - [StateNotifier] exposes a [mounted] boolean, to know if [dispose] was
///   called or not.
/// - listeners added with [addListener] receives the current [state] as parameter
/// - calling [addListener] immediately calls the listener with the current [state].
abstract class StateNotifier<T> {
  /// Initialize [state].
  StateNotifier(this._state);

  final _listeners = LinkedList<_ListenerEntry<T>>();

  /// A callback for error reporting if one of the listeners added with [addListener] throws.
  ///
  /// This callback should not throw.
  ///
  /// It exists merely for error reporting (mainly `FlutterError.onError`), and
  /// should not be used otherwise.\
  /// If you need an error status, consider adding an error property on
  /// your custom [state].
  ErrorListener onError;

  bool _mounted = true;

  /// Whether [dispose] was called or not.
  bool get mounted => _mounted;

  bool _debugCanAddListeners = true;

  bool _debugSetCanAddListeners(bool value) {
    assert(() {
      _debugCanAddListeners = value;
      return true;
    }());
    return true;
  }

  bool _debugIsMounted() {
    assert(() {
      if (!_mounted) {
        throw StateError('''
Tried to use $runtimeType after `dispose` was called.

Consider checking `mounted`.
''');
      }
      return true;
    }());
    return true;
  }

  T _state;

  /// The current "state" of this [StateNotifier].
  ///
  /// Updating this variable will synchronously call all the listeners.
  ///
  /// Updating the state will throw if at least one listener throws.
  @protected
  T get state {
    assert(_debugIsMounted());
    return _state;
  }

  /// A development-only way to access [state] outside of [StateNotifier].
  ///
  /// The only difference with [state] is that [debugState] is not "protected".\
  /// Will not work in release mode.
  ///
  /// This is useful for tests.
  T get debugState {
    T result;
    assert(() {
      result = _state;
      return true;
    }());
    return result;
  }

  @protected
  set state(T value) {
    assert(_debugIsMounted());
    _state = value;

    var didThrow = false;
    for (final listenerEntry in _listeners) {
      try {
        listenerEntry.listener(value);
      } catch (err, stack) {
        didThrow = true;
        onError?.call(err, stack);
      }
    }
    if (didThrow) {
      throw Error();
    }
  }

  /// If a listener has been added using [addListener] and hasn't been removed yet.
  bool get hasListeners {
    assert(_debugIsMounted());
    return _listeners.isNotEmpty;
  }

  /// Subscribes to this object.
  ///
  /// The [listener] callback will be called immediately on addition and
  /// synchronously whenever [state] changes.
  /// Set [fireImmediately] to false if you want to skip the first,
  /// immediate execution of the [listener].
  ///
  ///
  /// To remove this [listener], call the function returned by [addListener]:
  ///
  /// ```dart
  /// StateNotifier<Model> example;
  /// final removeListener = example.addListener((value) => ...);
  /// removeListener();
  /// ```
  ///
  /// Listeners cannot add other listeners.
  ///
  /// Adding and removing listeners is O(1).
  RemoveListener addListener(
    Listener<T> listener, {
    bool fireImmediately = true,
  }) {
    assert(() {
      if (!_debugCanAddListeners) {
        throw ConcurrentModificationError();
      }
      return true;
    }());
    assert(_debugIsMounted());
    final listenerEntry = _ListenerEntry(listener);
    _listeners.add(listenerEntry);
    try {
      assert(_debugSetCanAddListeners(false));
      if (fireImmediately) {
        listener(state);
      }
    } catch (err, stack) {
      listenerEntry.unlink();
      onError?.call(err, stack);
      rethrow;
    } finally {
      assert(_debugSetCanAddListeners(true));
    }

    return () {
      if (listenerEntry.list != null) {
        listenerEntry.unlink();
      }
    };
  }

  /// Frees all the resources associated to this object.
  ///
  /// This marks the object as no longer usable and will make all methods/properties
  /// besides [mounted] inaccessible.
  @mustCallSuper
  void dispose() {
    assert(_debugIsMounted());
    _listeners.clear();
    _mounted = false;
  }
}

class _ListenerEntry<T> extends LinkedListEntry<_ListenerEntry<T>> {
  _ListenerEntry(this.listener);

  final Listener<T> listener;
}

/// A mixin that adds service location capability to an object.
///
/// This makes the object aware of things like `Provider.of` or `GetIt`, without
/// actually depending on those.\
/// It also provides testing utilities to be able to mock dependencies.
///
/// In the context of Flutter + `provider`, adding that mixin to an object
/// makes it impossible to shared one instance across multiple "providers".
///
/// This mix-in does not do anything by itself.\
/// It is simply an interface for 3rd party libraries such as provider to implement
/// the logic.
///
/// See also:
///
/// - [read], to read objects
/// - [debugMockDependency], to mock dependencies returned by [read]
/// - [update], a new life-cycle to synchronize this object with external objects.
/// - [debugUpdate], a method to test [update].
mixin LocatorMixin {
  // ignore: prefer_function_declarations_over_variables
  Locator _locator = <T>() => throw DependencyNotFoundException<T>();

  /// A function that allows obtaining other objects.
  ///
  /// It is typically equivalent to `Provider.of(context, listen: false)` when
  /// using `provider`, but it could also be `GetIt.get` for example.
  ///
  /// **DON'T** modify [read] manually.\
  /// The only reason why [read] is not `final` is so that it can be
  /// initialized by providers from `flutter_notifier`.
  ///
  /// May throw a [DependencyNotFoundException] if the looked-up type is not found.
  @protected
  Locator get read {
    assert(_debugIsNotifierMounted());
    return _locator;
  }

  set read(Locator read) {
    assert(_debugIsNotifierMounted());
    _locator = read;
  }

  bool _debugIsNotifierMounted() {
    assert(() {
      if (this is StateNotifier) {
        final instance = this as StateNotifier;
        assert(instance._debugIsMounted());
      }
      return true;
    }());
    return true;
  }

  /// Overrides [read] to mock its behavior in development.
  ///
  /// This does nothing in release mode and is useful only for test purpose.
  ///
  /// A typical usage would be:
  ///
  /// ```dart
  /// class MyServiceMock extends Mock implements MyService {}
  ///
  /// test('mock dependency', () {
  ///   final myStateNotifier = MyStateNotifier();
  ///   myStateNotifier.debugMockDependency<MyService>(MyServiceMock());
  /// });
  /// ```
  void debugMockDependency<Dependency>(Dependency value) {
    assert(_debugIsNotifierMounted());
    assert(() {
      final previousLocator = read;
      read = <Target>() {
        assert(_debugIsNotifierMounted());
        if (Dependency == Target) {
          return value as Target;
        }
        return previousLocator<Target>();
      };
      return true;
    }());
  }

  /// A life-cycle that allows initializing the [StateNotifier] based on values
  /// coming from [read].
  ///
  /// This method will be called once, right before [update].
  ///
  /// It is useful as constructors cannot access [read].
  @protected
  void initState() {}

  /// A life-cycle that allows listening to updates on another object.
  ///
  /// This is equivalent to what "ProxyProviders" do using `provider`, but
  /// implemented with no dependency on Flutter.
  ///
  /// The property [read] is not accessible while inside the body of [update].
  /// Use the parameter passed to [update] instead, which will not just read the
  /// object but also watch for changes.
  @protected
  void update(Locator watch) {}

  var _debugDidInitState = false;

  /// A test utility to test the behavior of your [initState]/[update] method.
  ///
  /// The first time [debugUpdate] is called, this will call [initState] + [update].
  /// Further calls will only call [update].
  ///
  /// While inside [update], [read] will be disabled as it would be in production.
  void debugUpdate() {
    assert(() {
      if (!_debugDidInitState) {
        _debugDidInitState = true;
        initState();
      }

      final locator = read;
      read = <T>() => throw StateError('Cannot use `read` inside `update`');
      try {
        update(locator);
      } finally {
        read = locator;
      }
      return true;
    }());
  }
}

/// Thrown when tried to call [LocatorMixin.read<T>()], but the [T] was not found.s
class DependencyNotFoundException<T> implements Exception {}

Use this package as a library

1. Depend on it

Add this to your package's pubspec.yaml file:


dependencies:
  state_notifier: ^0.4.0

2. Install it

You can install packages from the command line:

with pub:


$ pub get

with Flutter:


$ flutter pub get

Alternatively, your editor might support pub get or flutter pub get. Check the docs for your editor to learn more.

3. Import it

Now in your Dart code, you can use:


import 'package:state_notifier/state_notifier.dart';
  
Popularity:
Describes how popular the package is relative to other packages. [more]
78
Health:
Code health derived from static analysis. [more]
100
Maintenance:
Reflects how tidy and up-to-date the package is. [more]
100
Overall:
Weighted score of the above. [more]
89
Learn more about scoring.

We analyzed this package on Mar 30, 2020, and provided a score, details, and suggestions below. Analysis was completed with status completed using:

  • Dart: 2.7.1
  • pana: 0.13.6

Dependencies

Package Constraint Resolved Available
Direct dependencies
Dart SDK >=2.1.0 <3.0.0
meta ^1.1.8 1.1.8
Dev dependencies
matcher ^0.12.6
mockito ^4.1.1
test ^1.12.0