Riverpod par la pratique : découverte des bases

Cet article a pour objectif de vous faire découvrir pas à pas la bibliothèque Riverpod à partir d'exemples d'utilisation.
Le code source est disponible sur le GitLab Ippon.

Il était une fois la gestion d’états

Riverpod est une bibliothèque de gestion d’états pour Flutter.
La "gestion d’états", ou "state management" en anglais, a pour responsabilité de mettre à disposition les différents objets qui constituent l'état de l'application.

La principale particularité de Flutter est que tout est widget.
La gestion d’états ne faisant pas exception, de nombreuses bibliothèques sont apparues proposant chacune leurs propres patterns et méthodes de fonctionnement.
Parmi les plus utilisées on peut citer BLoC, GetX, Provider et aujourd'hui c'est Riverpod qui nous intéresse.

Le créateur et principal contributeur de Riverpod, Remi Rousselet, n'en est pas à son premier ballon d'essai.
Reconnu au sein de la communauté Dart et Flutter, il est entre autre l'auteur de freezed (un générateur de code pour les data-classes et unions), flutter_hooks (une implémentation des hooks React) et Provider (une autre gestion d’états).
Cette dernière est le point de départ d'une réflexion plus ambitieuse sur la gestion d'états qui impliquera une réécriture complète pour aboutir à Riverpod.
Cet article fait référence à la bibliothèque Provider sous ces termes uniquement pour ne pas la confondre avec la classe Provider présente dans Riverpod.

En route avec Riverpod !

Dès son installation, Riverpod se distingue de ses concurrentes par son découpage en plusieurs bibliothèques :

La bibliothèque flutter_riverpod sera utilisée pour aborder l'ensemble des fondamentaux de Riverpod.
À l'heure de l'écriture de cet article, deux versions sont disponibles, une stable (1.0.3) et une en cours de développement (2.0.0-dev.9).
Dans un souci de pérennité, cette dernière sera utilisée tout au long de l'article.
La dépendance est à ajouter dans le fichier pubspec.yaml :

dependencies:
  flutter_riverpod: ^2.0.0-dev.9

Une histoire de Provider

L'opération la plus élémentaire est de récupérer un état stocké dans un ProviderContainer par l'intermédiaire d'un Provider.

Le ProviderContainer est un conteneur d'états.
Pour simplifier, c'est une Map avec comme clefs les instances des providers et comme valeurs les états correspondants.
Utiliser des instances comme clef corrige la limitation de la bibliothèque Provider qui ne supporte qu'une valeur par classe.

Le Provider est un moyen de récupérer un état présent dans un ProviderContainer.
La déclaration d'un provider permet d'indiquer le type et la valeur d'initialisation de l'état auquel il correspond.

Déclaration d'un Provider

Déclarer un provider permet de typer, initialiser et récupérer un état.

La récupération d'un état est réalisée de la manière suivante :

source : provider_test.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';

final intProvider = Provider<int>((_) => 13); // <1>

void main() {
  test('doit récupérer un état', () {
    // given:
    final container = ProviderContainer(); // <2>
    addTearDown(container.dispose);

    // expect:
    expect(container.read(intProvider), equals(13)); // <3>
  });
}

Provider

Le Provider est instancié en tant que variable globale et son état est initialisé avec la valeur 13 <1>.
L'instance du ProviderContainer contient les états <2> et permet de les récupérer en passant l'instance du Provider en paramètre de sa méthode read <3>.

Déclarer le Provider en tant que variable globale <1> peut sembler une erreur de conception, mais il n'en est rien.
Il est avant tout immutable et ne contient pas un état, mais constitue un moyen de le récupérer.
La visibilité globale est alors un choix judicieux pour le rendre disponible n'importe où dans le code.

Une autre particularité du Provider est de résoudre la principale erreur rencontrée par la bibliothèque Provider, à savoir le ProviderNotFoundException.
Cette erreur est levée quand un état est accédé alors qu'il n'a pas été encore initialisé.
Le provider étant responsable de l'initialisation de l'état, ce dernier sera systématiquement initialisé avant d'être récupéré.

Le contrat du Provider se limite à la récupération d'un état.
Les modifications internes de l’état ne sont pas notifiées pour favoriser l’utilisation d’objets immutables.

Plus généralement, l'immutabilité des objets échangés est, entre autres, une bonne pratique dans le développement événementiel, car elle limite les effets de bord.
La déclaration de ces objets peut se révéler fastidieuse en Dart et c'est ce que propose de simplifier la bibliothèque freezed.

Le cycle de vie des Providers

La récupération de son état n'est qu'une partie du cycle de vie d'un Provider.
Le paramètre ProviderRef, présent dans l'initialisation de chaque Provider, propose par sa méthode onDispose de définir une callback qui sera appelée pour libérer ses ressources.

source : circle_of_life_test.dart

void main() {
  test('doit vérifier le cycle de vie du Provider', () {
    const defaultValue = -1;

    // given:
    final container = ProviderContainer();

    var intValue = defaultValue; // <1>
    var disposed = false;

    final intProvider = Provider<int>((ref) {
      ref.onDispose(() => disposed = true);
      return intValue = 13;
    });

    // expect:
    expect(intValue, equals(defaultValue)); // <2>
    expect(disposed, isFalse);

    // when:
    container.read(intProvider); // <3>

    // then:
    expect(intValue, equals(13)); // <4>
    expect(disposed, isFalse);

    // when:
    container.dispose(); // <5>

    // then:
    expect(disposed, isTrue); // <6>
  });
}

Circle of Life

Le Provider est déclaré au sein du test pour retenir son affectation d'état et sa libération de ressources <1>.
Avant toute manipulation, sa valeur n'est toujours pas affectée <2>.
Ce n'est qu'après avoir été lu <3> que sa valeur est initialisée <4>.
Les ressources du Provider sont libérées <6> avec celles du ProviderContainer <5>.

En plus de contenir les états, le ProviderContainer gère aussi leurs cycles de vie.
Un état ne sera initialisé qu'à partir du moment où il sera lu, reflétant la nature paresseuse (lazy) du ProviderContainer.

Pour des questions de consommation mémoire, il est parfois nécessaire d'écourter la vie d'un Provider en libérant ses ressources dès qu'il n'est plus utilisé.
Ce comportement additionnel du Provider est ajouté avec le modifier autodispose :

Un modifier est une méthode supplémentaire appelée lors de la déclaration d'un Provider.

source : autodispose_test.dart

void main() {
  test('doit vérifier que les ressources du Provider sont libérées', () async {
    // given:
    final container = ProviderContainer();
    addTearDown(container.dispose);

    var disposed = false; // 1
    final provider = Provider.autoDispose<void>((ref) {
      ref.onDispose(() => disposed = true);
    });

    // when:
    container.read(provider); // <2>

    // then:
    expect(disposed, isFalse); // <3>

    // when:
    await Future.delayed(const Duration(milliseconds: 1)); // <4>

    // then:
    expect(disposed, isTrue); // <5>
  });
}

autodispose

Le Provider est déclaré pour que ses ressources soient libérées dès qu'il n'est plus utilisé <1>.
Après avoir été lu <2> ses ressources ne sont pas instantanément libérées <3>.
Ce n'est qu'après avoir lâché la main sur la boucle synchrone <4> que la libération asynchrone de ses ressources est effective <5>.

Un Provider déclaré avec le modifier autodispose libère son état de manière asynchrone dès qu'il n'est plus utilisé.

Test, test, test and test

Pour homogénéiser l'écriture des tests et éviter d'éventuels oublis de libération de ressources, la déclaration des ProviderContainer peut être factorisée comme le fait Riverpod avec la méthode createContainer.
Cette méthode sera par la suite utilisée dans les prochains tests de cet article.

source : utils.dart

import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod/riverpod.dart';

ProviderContainer createContainer({
  ProviderContainer? parent,
  List<Override> overrides = const [],
  List<ProviderObserver>? observers,
}) {
  final container = ProviderContainer(
    parent: parent,
    overrides: overrides,
    observers: observers,
  );
  addTearDown(container.dispose);
  return container;
}

Étant donné que le ProviderContainer contient les états, en instancier un nouveau pour chaque test permet de garantir leur isolation.

La variable overrides passée à l'initialisation du ProviderContainer va se révéler particulièrement utile pour préparer la situation initiale des tests.
La liste d'Overrides qu'elle contient vient surcharger le comportement du ProviderContainer.
Ces Overrides sont retournés par les méthodes idoines du Provider, overrideWithProvider pour le remplacer et overrideWithValue pour remplacer l'état récupéré.

source : overrides_test.dart

final intProvider = Provider<int>((_) => 13); // <1>
final otherIntProvider = Provider<int>((_) => 13);

void main() {
  test('doit surcharger le comportement des Provider', () {
    // given:
    final container = createContainer(
      overrides: [
        intProvider.overrideWithProvider(Provider<int>((_) => 42)), // <2>
        otherIntProvider.overrideWithValue(42),
      ],
    );

    // expect:
    expect(container.read(intProvider), 42); // <3>
    expect(container.read(otherIntProvider), 42);
  });
}

overrides

Deux Providers sont déclarés pour retourner la valeur 13 <1>.
Le comportement du ProviderContainer est surchargé pour remplacer le premier Provider et pour remplacer la valeur retournée par le second <2>.
La valeur récupérée par les deux est maintenant de 42 <3>.

Distinction entre états pour un Provider

Un même Provider peut se voir confier plusieurs états en attribuant à chacun une clef.
Le modifier family ajoute ce comportement à la déclaration du Provider en définissant le type de la clef avec son générique :

final evenProvider = StateProvider.family<bool, int>((_, arg) => arg.isEven); // <1>

void main() {
  test('doit distinguer chaque état dans un provider', () {
    // given:
    final container = createContainer();

    // then:
    expect(container.read(evenProvider(13)), isFalse); // <2>
    expect(container.read(evenProvider(42)), isTrue);
  });
}

.family

Un Provider est déclaré pour indiquer par ses états si la clef passée en paramètre est paire <1>.
La lecture du Provider se fait en passant cette clef et la valeur correspondante est retournée <2>.

Pour simplifier, le modifier family transforme le Provider en Map avec comme clefs son générique et comme valeurs les états correspondants.

Un Provider déclaré avec le modifier family dispose d'autant d'états que de clef qui leur sont attribuées.

Changer d'état avec le StateProvider

Le Provider est un moyen de récupérer un état, mais en aucun cas de le modifier.
Ce comportement a été confié au StateProvider de la manière suivante :

source : state_provider_test.dart

final intProvider = StateProvider((_) => 13); // <1>

void main() {
  test('doit modifier un état', () {
    // given:
    final container = createContainer();

    // expect:
    expect(container.read(intProvider), equals(13));

    // when:
    container.read(intProvider.notifier).state = 42; // <2>

    // expect:
    expect(container.read(intProvider), equals(42)); // <3>
  });
}

StateProvider

Le StateProvider est instancié de la même manière qu'un Provider, en initialisant son état avec la valeur 13 <1>.
Le notifier du StateProvider est récupéré par l'intermédiaire du ProviderContainer et son état est remplacé par la valeur 42 <2>.
L'état du StateProvider a bien été mis à jour avec cette nouvelle valeur <3>.

Le contrat du StateProvider va de la récupération à la modification d'un état.
Les modifications sont réalisées en affectant un nouvel objet à la variable state du notifier.
Chacune de ces modifications est ensuite notifiée aux objets qui le surveillent.

Ce fonctionnement implique la création d'un nouvel objet pour indiquer un changement d'état et conforte l'utilisation d'objets immutables.

La mise à jour de l'état par le StateProvider met en évidence un pattern récurrent chez Riverpod.
Étant donné que le ProviderContainer propose la méthode read, on aurait pu s'attendre qu'il propose son pendant, la méthode write.
D'un point de vue conceptuel, l'unique interaction que partagent toutes les classes de Provider avec le ProviderContainer est de récupérer un état, d'où l'unique présence de la méthode read.
Cependant, chaque classe de Provider dispose de son propre contrat et c'est par l'intermédiaire de providers additionnels que les comportements sont adaptés.
Dans le cas du StateProvider, c'est le provider additionnel notifier qui ajoute le changement de valeur.

Surveillance entre Providers

Un Provider peut surveiller un autre Provider dès son initialisation par l'intermédiaire de la méthode watch du paramètre ProviderRef :

source : watch_test.dart

final intProvider = StateProvider<int>((_) => 13); // <1>
final readProvider = Provider<int>((ref) => ref.read(intProvider));
final watchProvider = Provider<int>((ref) => ref.watch(intProvider));

void main() {
  test('doit surveiller un état', () {
    // given:
    final container = createContainer();

    // expect:
    expect(container.read(readProvider), equals(13)); // <2>
    expect(container.read(watchProvider), equals(13));

    // when:
    container.read(intProvider.notifier).state = 42; // <3>

    // expect:
    expect(container.read(readProvider), equals(13)); // <4>
    expect(container.read(watchProvider), equals(42));
  });
}

watch modifier

Le StateProvider intProvider est initialisé avec la valeur 13, readProvider vient lire son état et watchProvider le surveiller <1>.
Lors de leurs premières lectures, l'état des readProvider et watchProvider sont identiques à celui du intProvider <2>.
L'état du intProvider est modifié avec la valeur 42 <3> et seulement watchProvider prend en compte ce changement <4>.

Le comportement des méthodes read et watch est identique lors de la première initialisation, l'état du intProvider est récupéré pour être ensuite retourné.
C'est lors de la modification du intProvider <3> que les comportements divergent.
Le readProvider ne se préoccupe pas de cette nouvelle valeur alors que la watchProvider vient appeler de nouveau sa méthode d'initialisation <1> pour mettre à jour son état en adéquation avec celui du intProvider.

Un Provider peut écouter un autre Provider dès son utilisation pour former un graphe de dépendances.
Le ProviderRef peut être considéré comme une façade au ProviderContainer.
Le comportement attendu des méthodes read et watch est identique selon que l'on utilise un ProviderContainer dans le corps du test ou un ProviderRef dans l'initialisation du Provider.

Ce fonctionnement en façade est illustré par le code ci-dessous tiré de Riverpod :

source : provider_base.dart

@override
T read<T>(ProviderBase<T> provider) {
  _assertNotOutdated();
  assert(!_debugIsRunningSelector, 'Cannot call ref.read inside a selector');
  assert(_debugAssertCanDependOn(provider), '');
  return _container.read(provider);
}

À noter que la surveillance vient créer un lien de dépendance entre les Providers et peut mener à l'apparition de dépendances circulaires :

source : circular_dependency_test.dart

final Matcher throwsProviderException = throwsA(const TypeMatcher<ProviderException>());

final Provider<int> provider = Provider<int>((ref) => ref.watch(otherProvider)); // <1>
final Provider<int> otherProvider = Provider<int>((ref) => ref.watch(provider));

void main() {
  test('doit lever une exception suite à une dépendance circulaires', () {
    // given:
    final container = createContainer();

    // expect:
    expect(() => container.read(provider), throwsProviderException); // <2>
  });
}

Une interdépendance est déclarée entre deux Provider <1> et à la lecture de l'un d'entre eux une exception est levée <2>.

Riverpod dispose d'un mécanisme qui vient lever une exception quand une dépendance circulaire est détectée.

Le listener qui écoutait à l'oreille des Providers

Un autre moyen d'être notifié d'un changement d'état est de l'écouter avec la méthode listen proposée par le ProviderContainer.
Cette méthode prend en paramètre une callback qui sera appelée lors de chaque changement d'état en passant en paramètres l'ancienne et la nouvelle valeur.

source : listener_test.dart

final intProvider = StateProvider<int>((_) => 13); // <1>
final watchProvider = Provider<int>((ref) => ref.watch(intProvider));

void main() {
  test('doit écouter un état', () {
    const defaultValue = -1;

    // given:
    final container = createContainer();

    var intValue = defaultValue; // <2>
    container.listen<int>(intProvider, (_, next) => intValue = next);

    var watchValue = defaultValue;
    container.listen<int>(watchProvider, (_, next) => watchValue = next);

    // expect:
    expect(intValue, equals(defaultValue)); // <3>
    expect(watchValue, equals(defaultValue));

    // when:
    container.read(intProvider.notifier).state = 42; // <4>

    // then:
    expect(intValue, equals(42)); // <5>
    expect(watchValue, equals(defaultValue));

    // when:
    container.read(watchProvider); // <6>

    // then:
    expect(intValue, equals(42)); // <7>
    expect(watchValue, equals(42));
  });
}

listener

Le StateProvider intProvider est initialisé avec la valeur 13 et watchProvider surveille ses modifications <1>.
Des listeners écoutent leurs changements d'état respectif pour stocker les nouvelles valeurs <2>.
Sans aucune modification, ces valeurs écoutées conservent leurs valeurs par défaut <3>.
Après la modification de l'état du intProvider avec la valeur 42 <4> seulement son listener a été notifié <5>.
Ce n'est qu'après la lecture du watchProvider que son listener est notifié <6>.

Écouter n'est pas surveiller.

Écouter un provider avec la méthode listen permet d'être notifié lors d'un changement d'état.
Ce changement d'état ne sera effectif qu'à partir du moment où il sera lue et non à partir du moment où il a été modifié, c'est un fonctionnement passif.
C'est pour cette raison que la valeur de watchValue conserve sa valeur par défaut <6>.

Surveiller un provider avec la méthode watch vient lire le nouvel état à la suite d'un changement.
Ce changement d'état est effectif dès sa modification, c'est un fonctionnement actif.
Cependant, le fonctionnement paresseux du ProviderContainer ne propagera le changement <7> qu'à partir du moment où le Provider sera lu <6>.

Écouter est un fonctionnement passif alors que surveiller est un fonctionnement actif.
En plus d'être paresseux à l'initialisation des états, le ProviderContainer l'est également lors leurs modifications.

Devenir sélectif dans les changements d'états

Être notifié par tous les changements d'états peut mener à des pertes de performances.
Ce problème est résolu par la méthode select des Provider qui permet d'agréger leur état pour ne conserver que les valeurs utiles.

source : select_test.dart

final intProvider = StateProvider((_) => 13); // <1>
final moduloProvider = Provider<int>((ref) => ref.watch(intProvider.select((state) => state % 10)));

void main() {
  test('doit écouter le modulo 10', () {
    // given:
    final container = createContainer();

    // and:
    var called = 0;
    container.listen(moduloProvider, (_, __) => called++); // <2>

    // expect:
    expect(container.read(moduloProvider), equals(3)); // <3>
    expect(called, equals(0));

    // when:
    container.read(intProvider.notifier).state = 42; // <4>

    // then:
    expect(container.read(moduloProvider), equals(2)); // <5>
    expect(called, equals(1));

    // when:
    container.read(intProvider.notifier).state = 22; // <6>

    // expect:
    expect(container.read(moduloProvider), equals(2)); // <7>
    expect(called, equals(1));
  });
}

select

Le StateProvider intProvider est initialisé avec la valeur 13 et le moduloProvider vient l'écouter en sélectionnant uniquement le modulo de 10 de l'état <1>.
Un compteur écoute le nombre de changements réalisés par moduloProvider <2>.
Initialement le modulo de 13 vaut 3 et aucun changement n'est encore réalisé <3>.
Suite à la modification de la valeur de intProvider par 42 <4>, le modulo vaut 2 et un changement est ajouté au compteur <5>.
La valeur du intProvider est à nouveau modifiée avec la valeur 22 <6> mais étant donné que son modulo est identique à celui de 42 aucune modification n'est apportée à l'état du moduloProvider <7>.

À noter que cet exemple n'a vocation qu'à présenter la théorie : l'utilisation du select dans l'initialisation d'un provider ne présente aucun intérêt étant donné que le comportement est identique à celui d'un Provider effectuant lui-même l'opération :

final moduloProvider = Provider<int>((ref) => ref.watch(intProvider) % 10);

Le select prendra tout son intérêt lors de l'intégration avec Flutter afin de ne conserver que les données utiles à surveiller pour économiser les rebuild et gagner en performances.

Accéder au notifier avec le StateNotifierProvider

Jusqu'à présent les Providers ne donnaient accès qu'à un état, qu'il soit non modifiable avec un Provider ou modifiable avec un StateProvider par l'intermédiaire de son notifier.
Le StateNotifierProvider donne accès à ce notifier pour permettre au développeur de l'enrichir avec de nouvelles méthodes :

final incrementProvider = StateNotifierProvider<IncrementNotifier, int>(
      (ref) => IncrementNotifier(ref.watch(intProvider)),
);

class IncrementNotifier extends StateNotifier<int> {
  IncrementNotifier(int value) : super(value);

  void increment() {
    state++;
  }
}

Le StateNotifierProvider est formé de deux composants : le contenant avec la classe StateNotifier et le contenu avec sa variable state.
Chacun dispose de son propre cycle de vie, celui de l'état étant dépendant de celui du notifier :

source : state_notifier_provider_test.dart

final intProvider = StateProvider<int>((ref) => 13); // <1>

final incrementProvider = StateNotifierProvider<IncrementNotifier, int>(
      (ref) => IncrementNotifier(ref.watch(intProvider)),
);

class IncrementNotifier extends StateNotifier<int> {
  IncrementNotifier(int value) : super(value);

  void increment() { // <2>
    state++;
  }
}

void main() {
  test('doit incrémenter un état', () {
    // given:
    final container = createContainer();

    // expect:
    expect(container.read(incrementProvider), equals(13)); // <3>

    // when:
    container.read(incrementProvider.notifier).increment(); // <4>

    // then:
    expect(container.read(incrementProvider), equals(14)); // <5>

    // when:
    container.read(intProvider.notifier).state = 42; // <6>

    // then:
    expect(container.read(incrementProvider), equals(42)); // <7>
  });
}

state_notifier_provider

Le StateProvider intProvider est initialisé avec la valeur 13 <1>.
Il est écouté par le StateNotifierProvider incrementProvider dont le notifier IncrementNotifier dispose d'une méthode pour incrémenter son état <2>.
L'état récupéré par le incrementProvider est bien celui du intProvider <3>.
L'appel à la méthode increment <4> fait passer la valeur du incrementProvider de 13 à 14 <5>.
Après avoir modifié l'état du intProvider, le incrementProvider est initialisé à nouveau et prend la valeur 42.

Le contrat du StateNotifierProvider va de la récupération à la modification de l'état.
La classe StateNotifier est à étendre en indiquant le type de l'état en générique et de passer sa valeur initiale au constructeur parent.
L'état est stocké dans la variable state avec une visibilité limitée en protected pour conserver une implémentation étanche.
Les modifications sont réalisées en affectant un nouvel objet à la variable state.
Chacune de ces modifications est ensuite notifiée aux objets qui le surveillent.

ChangeNotifierProvider, le vilain petit canard

Jusqu'à présent les états se devaient d'être immutables mais, pour des questions de performances ou de conception, il est parfois nécessaire d'abandonner cette bonne pratique.
Le ChangeNotifierProvider répond à ce cas de figure en laissant à la charge du développeur de notifier les changements apportés à l'état :

final incrementProvider = ChangeNotifierProvider<IncrementNotifier>(
      (ref) => IncrementNotifier(13),
);

class IncrementNotifier extends ChangeNotifier {
  IncrementNotifier(this.value);

  int value;

  void increment() {
    value++; // <1> Modification de l'état
    notifyListeners(); // <2> Notification de l'état
  }
}

Comme le StateNotifierProvider, il se compose d'un contenant avec le ChangeNotifier mais cette fois-ci qui déclare lui-même son propre contenu.

Le contrat du ChangeNotifierProvider va de la récupération à la modification de l'état.
La classe ChangeNotifier est à étendre et les changements internes sont à notifier manuellement en appelant la méthode notifyListeners.
Les notifications sont ensuite transmises aux objets qui les surveillent.

source : change_notifier_provider_test.dart

final incrementProvider = ChangeNotifierProvider<IncrementNotifier>(
      (ref) => IncrementNotifier(13), // <1>
);

class IncrementNotifier extends ChangeNotifier {
  IncrementNotifier(this.number);

  int number;

  void increment() {
    number++;
    notifyListeners();
  }
}

void main() {
  test('doit incrémenter un état', () {
    // given:
    final container = createContainer();

    // expect:
    expect(container.read(incrementProvider.notifier).number, equals(13)); // <2>

    // when:
    container.read(incrementProvider).increment(); // <3>

    // then:
    expect(container.read(incrementProvider).number, equals(14)); // <4>
  });
}

change_notifier_provider

La classe IncrementNotifier, un ChangeNotifier avec une méthode increment pour incrémenter sa propriété number, est initialisé avec la valeur 13 <1>.
La propriété number de son notifier dispose bien de la valeur 13.
Après avoir été incrémentée <3>, la valeur prend la valeur 14 <4>.

Aucune séparation n'étant faite entre le contenant et le contenu du ChangeNotifier, il est retourné par le ProviderContainer aussi bien en tant qu'état <2>, qu'en tant que provider additionnel notifier <4>.

À noter que la classe ChangeNotifier est initialement proposée par Flutter pour fournir un mécanisme d'écoute et de notification.
Le ChangeNotifierProvider est donc parfois utile pour migrer d'anciennes applications utilisant le ChangeNotifier comme gestion d'états.

Détour sur le Future avec le FutureProvider

Le Future est mutable et utilise des callbacks alors que Riverpod prône l'immutabilité et utilise des états.
Ces deux conceptions, radicalement opposées, deviennent compatible en adaptant le Future par un FutureProvider.

Le FutureProvider retourne l'interface[1] AsyncValue dont les implémentations traduisent en états immutables les différentes étapes d'un Future.
Le chargement, les callbacks de retour et d'erreur sont respectivement représentés par ses factories AsyncValue.loading, AsyncValue.data et AsyncValue.error.
À cela vient s'ajouter la méthode statique AsyncValue.guard pour adapter une fonction retournant un Future en Future d'AsyncValue.

L'utilisation de lAsyncValue est simplifiée par les méthodes de son extension AsyncValueX, inspirée par les unions de freezed, en proposant un équivalent au pattern matching.

source : future_provider_test.dart

const duration = Duration(milliseconds: 100);

final asyncIntProvider = FutureProvider<int>( // <1>
      (ref) => Future.delayed(duration, () => 13),
);

void main() {
  test('doit consommer un futur', () async {
    // given:
    final container = createContainer();

    // expect:
    expect(container.read(asyncIntProvider), equals(const AsyncValue<int>.loading())); // <2>

    // when:
    await Future.delayed(duration + const Duration(milliseconds: 50)); // <3>

    // then:
    expect(container.read(asyncIntProvider), equals(const AsyncValue.data(13))); // <4>
  });
}

future_provider

Un FutureProvider est instancié pour que son état prenne la valeur 13 après 100ms <1>.
Tant que le Futur n'a pas été résolu, aucune valeur n'est attribuée à l'état et il conserve la valeur AsyncLoading <2>.
Après avoir attendu 150ms <3>, l'état devient un AsynData avec pour valeur 13 <4>.

Le flux et le StreamProvider

Le fonctionnement d'une Stream est similaire à celui d'un Future, à ceci près qu'elle peut retourner plusieurs valeurs durant son cycle de vie :

source : stream_provider_test.dart

const duration = Duration(milliseconds: 100);

final asyncIntProvider = StreamProvider<int>( // <1>
  (ref) async* {
    await Future.delayed(duration);
    yield 13;

    await Future.delayed(duration);
    throw Error();
  },
);

void main() {
  test('doit consommer une stream', () async {
    // given:
    final container = createContainer();

    // expect:
    expect(container.read(asyncIntProvider), equals(const AsyncValue<int>.loading())); // <2>

    // when:
    await Future.delayed(duration + const Duration(milliseconds: 50)); // <3>

    // then:
    expect(container.read(asyncIntProvider), equals(const AsyncValue.data(13))); // <4>

    // when:
    await Future.delayed(duration); // <5>

    // then:
    expect(container.read(asyncIntProvider), isInstanceOf<AsyncError>()); // <6>
  });
}

stream_provider

Une Stream est déclarée pour retourner la valeur 13 suivie d'une erreur, le tout entrecoupé par un délai de 100 millisecondes <1>.
Tant que la première valeur de la Stream n'est pas retournée, l'état conserve la valeur AsyncLoading <2>.
Après avoir attendu 150ms <3>, l'état devient un AsynData avec pour valeur 13 <4>.
Une exception est levée 100ms plus tard <5> et l'état retourne une AsyncError <6>.

L'aventure ne fait que commencer...

Ainsi se termine ce premier article sur Riverpod.
Vous disposez maintenant des bases pour en comprendre les principaux mécanismes.

Le prochain article sera plus court et portera sur son intégration avec Flutter.

zelda_sword


  1. Les interfaces n'existent pas à proprement parler en Dart, ce sont des classes abstraites avec des méthodes abstraites dont l'interface implicite est implémentée. ↩︎