Managing Shared State in Flutter
When it comes to scalability and speed of development of a major code base, the communication between an ever growing number of components should be seamless. It felt like most of the existing Dart solutions for utilizing shared state were either too hard to maintain or did not satisfy my needs (coming from an NgRx background). So I decided to implement my own, lightweight solution.
Core Requirements
- Change shared state without a lot of boilerplate.
- React to state changes by utilizing Streams.
- Everything is immutable.
- Be type safe all the way (turned out to be the hardest challenge).
Dispatcher
The dispatcher contains the state and provides methods for updating state and sending and listening to actions for cross component communication.
abstract class AppAction {
@override
String toString() {
return '[${this.runtimeType}]';
}
}
class Dispatcher {
Stream<Map<String, dynamic>> get state => _state.stream;
final _state = BehaviorSubject<Map<String, dynamic>>.seeded({});
final _messages = PublishSubject<AppAction>();
Dispatcher();
Observable<T> on<T extends AppAction>() {
return _messages.where((m) => m is T).cast<T>();
}
void send(AppAction action) {
_messages.add(action);
}
void updateState<T>(String property, T fn(T all)) {
var state = _state.value;
_state.add({...state, property: fn(state[property])});
}
}
Entities
Entities are properties within the state that keep track of one entry (SingleEntity) or a collection (MultiEntity). Encapsulated entities are discouraged by design, as the state tree should be as flat as possible.
@immutable
abstract class EntityData {
Map<String, dynamic> toJson();
EntityData.fromJson(Map<String, dynamic> json);
EntityData copy(Map<String, dynamic> additional);
EntityData();
}
class SingleEntity<T> extends Stream {
final Dispatcher _dispatcher;
final String property;
SingleEntity(
this._dispatcher, {
@required this.property,
T initialValue,
}) {
if (initialValue != null) {
update((_) => initialValue);
}
}
void update(T fn(T prev)) {
_dispatcher.updateState<T>(property, fn);
}
T _select(dynamic state) => state[property];
@override
StreamSubscription<T> listen(void onData(T event),
{Function onError, void onDone(), bool cancelOnError}) {
return _dispatcher.state.map(this._select).listen(onData,
onError: onError, onDone: onDone, cancelOnError: cancelOnError);
}
}
class MultiEntity<T extends EntityData, IdentifierT> extends Stream {
final Dispatcher _dispatcher;
final String property;
IdentifierT Function(T) identify;
MultiEntity(
this._dispatcher, {
@required this.property,
@required this.identify,
List<T> initialValue,
}) {
addAll(initialValue ?? []);
}
void addOne(T value) {
_dispatcher.updateState<List<T>>(property, (all) => [...?all, value]);
}
void addAll(List<T> values) {
_dispatcher.updateState<List<T>>(property, (_) => values);
}
void removeOne(IdentifierT identifier) {
_dispatcher.updateState<List<T>>(property,
(all) => (all ?? []).where((entry) => identify(entry) != identifier));
}
void updateOne(T value) {
IdentifierT identifier = identify(value);
_dispatcher.updateState<List<T>>(
property,
(List<T> all) => (all ?? [])
.map((entry) => identify(entry) == identifier ? value : entry)
.toList(),
);
}
Stream<T> selectOne(IdentifierT identifier) {
return _dispatcher.state.map(_select).map((all) => (all ?? [])
.firstWhere((entry) => identify(entry) == identifier, orElse: null));
}
List<T> _select(dynamic state) => state[property];
@override
StreamSubscription<List<T>> listen(void onData(List<T> event),
{Function onError, void onDone(), bool cancelOnError}) {
return _dispatcher.state.map(this._select).listen(onData,
onError: onError, onDone: onDone, cancelOnError: cancelOnError);
}
}
Entity Data Types
Entities can either hold native data types or objects. Such objects have to implement fromJson, toJson and the copy method as shown in the following example. This might seem like a bit of an overhead, but because Reflection cannot be used with Flutter, we have to provide these methods to ensure type safety.
class Player extends EntityData {
final int id;
final String name;
Player.fromJson(Map<String, dynamic> json)
: id = json['id'],
name = json['name'];
@override
Map<String, dynamic> toJson() => {
'id': id,
'name': name,
};
@override
Player copy(Map<String, dynamic> additional) => Player.fromJson({
...toJson(),
...additional,
});
}
Creating a Dispatcher and the Store
I recommend creating a store class that holds the entities. In this example, the `score` holds one value only while there can be multiple players.
final dp = Dispatcher();
abstract class Store {
static final score = SingleEntity<int>(dp,
initialValue: 0,
property: 'score',
);
static final players = MultiEntity<Player, int>(dp,
initialValue: [],
property: 'players',
identify: (player) => player.id,
);
}
Working with this architecture
class GameOverAction extends AppAction {
final int score;
GameOverAction(this.score);
}
/// visualize the stream `Store.score`
/// update the score when the game is over
dp.on<GameOverAction>()
.listen((action) => Store.score.update(
(prevScore) => action.score)
);
/// game over!
dp.send(GameOverAction(9000));