Introducing Property Change Notifier

Martin Rybak
Flutter NYC
Published in
7 min readSep 4, 2019

A drop-in replacement for ChangeNotifier for observing only certain properties of a model.

If you are a Flutter developer, chances are you’ve come across ChangeNotifier. It is an implementation of the Observer pattern and is used to allow multiple listeners to observe a model for changes. It is easy to understand and implement. Here is an example:

class MyModel with ChangeNotifier {
int _foo;
int _bar;
int get foo => _foo;
int get bar => _bar;
set foo(int value) {
_foo = value;
notifyListeners();
}
set bar(int value) {
_bar = value;
notifyListeners();
}
}

Implementers just need to extend or mix-in ChangeNotifier and convert any public properties to getters and setters backed by private fields. Then in each setter, and any mutator methods, call notifyListeners(). Any listeners will then be notified. A listener is a simple VoidCallback and can be added using the following syntax:

final model = MyModel();
model.addListener(_listener);
void _listener() {
print('Model changed!');
}

The problem is this is an all-or-none approach. As your class grows in size, even small mutations will trigger model-wide change notifications. If you are using Flutter, that can result in a lot of unnecessary widget rebuilds. You may find yourself working around this by not calling notifyListeners() for some mutations, and it’s a slippery slope from there.

The problem is that the ChangeNotifier implementation provides no way to broadcast or listen to specific properties only. To do so would require every property to be implemented as a ValueNotifier or similar. The Observable package has some powerful tools but they are not backwards-compatible with ChangeNotifier.

That’s why we created PropertyChangeNotifier, a drop-in replacement for ChangeNotifier that implements a more granular observer pattern similar to PropertyChangeListener in Java and INotifyPropertyChanged in .NET. When a property changes, the name of the property is included in the notification. Listeners can then choose to observe one or more properties only.

What do we mean by “drop-in replacement”? It means that PropertyChangeNotifier can be used in place of ChangeNotifier and existing listeners can continue working without modification. Then you can incrementally migrate the listeners over time. Here’s an example:

class MyModel with PropertyChangeNotifier<String> {
int _foo;
int _bar;
int get foo => _foo;
int get bar => _bar;
set foo(int value) {
_foo = value;
notifyListeners('foo');
}
set bar(int value) {
_bar = value;
notifyListeners('bar');
}
}

First, update your model to extend or mix-in PropertyChangeNotifier. Notice that it takes an additional generic type parameter. This is the type you wish to use for properties (typically String). Then simply provide the property name as a parameter to notifyListeners().

As promised, existing listeners will continue to function as expected. The following legacy listener will continue to be invoked on every property change:

final model = MyModel();
model.addListener(_listener);
void _listener() {
print('Model changed!');
}

However, now we can be more selective with our listeners! To listen to one or more properties, include them as a parameter to the addListener() method. Let’s create a few different kinds of listeners:

The parameter list can be a List, Set, or any Iterable.

final model = MyModel();
model.addListener(_globalListener);
model.addListener(_fooListener, ['foo']);
model.addListener(_bothListener, ['foo', 'bar']);
void _globalListener() {
print('Model changed!');
}
void _fooListener() {
print('Foo changed!');
}
void _bothListener() {
print('Foo or bar changed!');
}

To know what specific property changed, a listener can optionally accept a property parameter. This applies to global listeners as well.

final model = MyModel();
model.addListener(_globalListener);
model.addListener(_bothListener, ['foo', 'bar']);
void _globalListener(String property) {
print('$property changed!');
}
void _bothListener(String property) {
print('$property changed!');
}

Property names

Referring to properties using string literals is error-prone and leads to stringly-typed code. To avoid this, you can reference string constants in both your model and listeners so that they can be safely checked by the compiler:

// Properties
abstract class MyModelProperties {
static String get foo => 'foo';
static String get bar => 'bar';
}
// Model
class MyModel extends PropertyChangeNotifier {
set foo(int value) {
_foo = value;
notifyListeners(MyModelProperties.foo);
}

}
// Listener
final model = MyModel();
model.addListener(_listener, [MyModelProperties.foo]);

You can even use an Enum, or any type that extends Object and correctly implements equality using == and hashCode.

PropertyChangeNotifier provides the ease and simplicity of ChangeNotifier, but with the added granularity needed for more efficient change notifications! 👏

Usage with Widgets

How do we use PropertyChangeNotifier with Widgets? Inspired by the Provider package, we created a widget called PropertyChangeProvider that uses InheritedWidget under the hood. First, create a PropertyChangeProvider widget with an instance of your model:

PropertyChangeProvider(
value: MyModel(),
child: MyApp(...)
};

The model instance can come from anywhere, including Provider or a regular state variable in a StatefulWidget.

Then, from any descendant widget, listen for changes to all or some properties by using the standard of() syntax. You can then access both the model itself and the properties that were changed in the current build frame. Here are a few different examples:

// Rebuilds when any property changes
class GlobalListener extends StatelessWidget {
@override
Widget build(BuildContext context) {
PropertyChangeProvider.of<MyModel>(context);
return Text('MyModel changed!);
}
}
// Rebuilds when foo changes
class FooListener extends StatelessWidget {
@override
Widget build(BuildContext context) {
final model = PropertyChangeProvider.of<MyModel>(context, properties: ['foo']).value;
return Text('Foo changed to ${model.foo}');
}
}
// Rebuilds when foo or bar changes
class BothListener extends StatelessWidget {
@override
Widget build(BuildContext context) {
final properties = PropertyChangeProvider.of<MyModel>(context, properties: ['foo', 'bar']).properties;
return Text('$properties changed');
}
}

Note: properties will be empty on the initial build, because the model has not changed yet. Be sure to handle this case.

Listening without rebuilding

By simply calling the of() method from a widget’s build() method, that widget is automatically registered to rebuild on model changes. To access a model without registering for a rebuild, add a listen parameter with a value of false. In this example below, RaisedButton does not depend on the value of foo and so does not need to be rebuilt if it changes.

// Will not be rebuilt when model changes
class NotListener extends StatelessWidget {
@override
Widget build(BuildContext context) {
final model = PropertyChangeProvider.of<MyModel>(context, listen: false).value;
return RaisedButton(
child: Text('Update foo'),
onPressed = () => model.foo++
};
}
}

Consumer widget

We also created PropertyChangeConsumer, a widget-based listener for cases where a BuildContext is hard to access, or if you prefer this kind of API. You can access both the model value and the changed properties via the builder callback:

class ConsumerListener extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PropertyChangeConsumer<MyModel>(
properties: ['foo', 'bar'],
builder: (context, model, properties)
{
return Column(
children: [
Text('$properties changed!'),
RaisedButton(
child: Text('Update foo'),
onPressed: () => model.foo++,
),
RaisedButton(
child: Text('Update bar'),
onPressed: () => model.bar++,
),
],
);
},
);
}
}

Implementation Details

PropertyChangeNotifier was relatively straightforward to implement. It replicates much of the behavior of ChangeNotifier, but instead of storing listeners in a single List, it stores them in a Map where the key is the property name and the value is a List of listeners. The way that listeners are invoked in ChangeNotifier is interesting (and we replicated it). While looping through the list and invoking listeners, there is the risk that a listener might itself add or remove other listeners. This would mutate the list while iterating through it, which would throw an exception. So first the list is copied before being iterated. But there is still the risk that a listener will be removed after making the copy. So, before invoking each listener, we check to see if it is still contained in the original list. To make this process more efficient, ChangeNotifier uses a special List subclass called ObserverList, which uses a backing HashSet to determine whether it contains a given item.

The implementation of PropertyChangeProvider was more interesting. We used InheritedWidget because it allows us to register a descendant widget to be rebuilt if needed. This is typically done by calling context.inheritFromWidgetOfExactType(). However this gives us no mechanism for limiting rebuilds to only certain properties of PropertyChangeNotifier. Fortunately, we can use InheritedModel instead. InheritedModel is a subclass of InheritedWidget that lets a descendant widget subscribe to certain aspects of a model by callingInheritedModel.inheritFrom(context, aspect: aspect). In our InheritedModel subclass, we check each dependent widgets’ aspect list against the current changed properties set to determine if it should be rebuilt. This implementation has the added benefit of being efficient. For every PropertyChangeProvider, there is only one model listener — the provider itself. So no need to worry about the performance implications of adding many descendant widgets listening to that provider.

Tests

A library that is as foundational as PropertyChangeNotifier must be tested thoroughly, and we are happy to announce that it currently has 100% test coverage. There are roughly twice as many LOC in the unit tests as there are in the implementation! So you can feel confident using it. Of course, if we missed anything, let us know!

We hope you find PropertyChangeNotifier as useful as we have! Feel free to file an issue or feature request. One known issue is the lack of generic type safety of the properties parameter in both PropertyChangeProvider and PropertyChangeConsumer. This could be solved by passing an additional generic type to the of() method, but we think this muddies up the API a bit. Also, strongly typing the properties Set type of PropertyChangeModel is tricky because it seems to break InheritedModel.inheritFrom(). Any suggestions or contributions are welcome!

Very Good Ventures is the world’s premier Flutter technology studio. We built the first-ever Flutter app in 2017 and have been on the bleeding edge ever since. We offer a full range of services including consultations, full-stack development, team augmentation, and technical oversight. We are always looking for developers and interns, so drop us a line! Tell us more about your experience and ambitions with Flutter.

--

--