About Me

Managing Shared State in Flutter

Christoph Bühler, Januar 2020

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));