🌊

Riverpodの基本的な使い方を整理してみる2

6 min read

前回の投稿ではRiverpodでの値の受け渡し・更新・組み合わせに関して整理した。今日は、もう少し細かい部分の使い方を整理しておきたいと思う。

https://zenn.dev/umatoma/articles/2026ef43bdb0f4

同じ様な処理に使い回す

IDが違うけど同じテーブルのデータを取得したりすることは普通にあると思う。しかし、毎回別々のProviderを用意するのはちょっと面倒である。そんな時はProviderが引数を受け取れるようにすれば良い。

class User {
 User(this.id, this.name);

 final String id;
 final String name;
}

final userProvider = Provider.family<User, String>((ref, userID) {
 if (userID == 'id_a') {
   return User('id_a', 'user_a');
 }
 if (userID == 'id_b') {
   return User('id_b', 'user_b');
 }
 return User('id_x', 'user_x');
});

class MyWidget extends HookWidget {
 
 Widget build(BuildContext context) {
   final userA = useProvider(userProvider('id_a'));
   final userB = useProvider(userProvider('id_b'));

   return Scaffold(
     body: Column(
       children: [
         ListTile(
           title: Text(userA.id),
           subtitle: Text(userA.name),
         ),
         ListTile(
           title: Text(userB.id),
           subtitle: Text(userB.name),
         ),
       ],
     ),
   );
 }
}

.famliyを使うことによって、Providerが引数を受け取れるようになる。また、Providerを組み合わせるともう少し便利に使える。

final userProvider = Provider.family<User, String>((ref, userID) {
 if (userID == 'id_a') {
   return User('id_a', 'user_a');
 }
 if (userID == 'id_b') {
   return User('id_b', 'user_b');
 }
 return User('id_x', 'user_x');
});

final userIDsProvider = Provider((ref) {
 return ['id_a', 'id_b'];
});

final usersProvider = Provider((ref) {
 final userIDs = ref.watch(userIDsProvider);
 return [
   for (final userID in userIDs) ref.watch(userProvider(userID)),
 ];
});

class MyWidget extends HookWidget {
 
 Widget build(BuildContext context) {
   final users = useProvider(usersProvider);

   return Scaffold(
     body: Column(
       children: [
         for (final user in users)
           ListTile(
             title: Text(user.id),
             subtitle: Text(user.name),
           ),
       ],
     ),
   );
 }
}

どちらの方法が適切かは場合によると思うが、状況に応じて使い分けられるよう選択肢として把握しておくと良さそう。

特定の条件下で処理を上書きする

一覧から詳細ページを表示するときなど、特定の条件下で処理を上書きしたい場合がある。

class MyWidget extends HookWidget {
 
 Widget build(BuildContext context) {
   final users = useProvider(usersProvider);

   return Scaffold(
     body: Column(
       children: [
         for (final user in users)
           ListTile(
             onTap: () => Navigator.of(context).push(
               MaterialPageRoute(
                 builder: (_) => ProviderScope(
                   overrides: [
                     userIDProvider.overrideWithValue(user.id),
                   ],
                   child: UserWidget(),
                 ),
               ),
             ),
             title: Text(user.id),
             subtitle: Text(user.name),
           ),
       ],
     ),
   );
 }
}

final userIDProvider = ScopedProvider((ref) => '');

class UserWidget extends HookWidget {
 
 Widget build(BuildContext context) {
   final userID = useProvider(userIDProvider);
   final user = useProvider(userProvider(userID));

   return Scaffold(
     body: Column(
       children: [
         Text(user.id),
         Text(user.name),
       ],
     ),
   );
 }
}

上書き対象のProviderをScopedProviderで定義し、ProviderScopeでWidgetをくくりつつ.overrideWithValue()でProviderの返す値を上書きできる。

ただし、ScopedProviderはAlwaysAliveProviderBaseを継承していないので、ref.watchなどで他のProviderと組み合わせることは出来ない。ScopedProviderはローカル変数で、他のProviderはグローバル変数と扱いが異なるようなイメージ。

また、テストコードを書くときなどProviderそのものをMockなどに差し替えたい場合がある。その時は.overrideWithProvider()を使えば良い。

import 'package:riverpod/riverpod.dart';

final helloProvider = Provider((ref) => 'HELLO');

void main() {
 final container = ProviderContainer(
   overrides: [
     helloProvider.overrideWithProvider(Provider((ref) => 'WORLD')),
   ],
 );
 final hello = container.read(helloProvider);
 print(hello);
 // WORLD
}

Flutterに依存しない純粋なDartとしてのテストコードを書く時はProviderScopeではなくProviderContainerを使う。ScopedProviderとは異なりグローバルな値を差し替えることになるので、Flutterのアプリケーションコード内で使う必要性がある場合は、最上位のProviderScopedで上書きを宣言する必要がある。

Future/Streamを扱う

APIからデータを取ってきて表示する場合など、FutureやStreamを通してデータを渡す事は多々ある。

final futureProvider = FutureProvider((ref) async {
 await Future.delayed(Duration(seconds: 3));
 return await Future.value('HELLO');
});

class MyWidget extends HookWidget {
 
 Widget build(BuildContext context) {
   final asyncValue = useProvider(futureProvider);

   return Scaffold(
     body: asyncValue.when(
       data: (data) {
         return Text(data);
       },
       loading: () {
         return Text('...');
       },
       error: (e, stackTrace) {
         return Text(e.toString());
       },
     ),
   );
 }
}

FutureProvider/StreamProviderを使えば良い。非同期処理の結果ではなくAsyncValueが渡ってくるので.when()で状態に応じで処理を切り替えることが簡単に出来る。StreamProviderも使い方は同じ。

final futureProvider = FutureProvider((ref) async {
 await Future.delayed(Duration(seconds: 3));
 return await Future.value('HELLO');
});

class MyWidget extends HookWidget {
 
 Widget build(BuildContext context) {
   final asyncValue = useProvider(futureProvider);

   return Scaffold(
     body: asyncValue.maybeWhen(
       error: (e, stackTrace) {
         return Text(e.toString());
       },
       orElse: () {
         final data = asyncValue.data?.value ?? '...';
         return Text(data);
       },
     ),
   );
 }
}

.when()だけでなく.maybeWhen()も用意されている。エラー時だけ処理を分けたい場合などに使える。

不要になったProviderを自動的に破棄する

Widgetが表示されなくなった時など、参照していたProviderが不要になる場面は多々ある。不要な参照が残ったままだとメモリにデータがどんどん溜まっていってしまうので可能であれば破棄しておきたい。

class UserRepository {
 Stream<User> getUsers() { // ユーザー取得処理 }
 void close() { // 何かしらのクローズ処理 }
}

final usersProvider = StreamProvider.autoDispose((ref) {
 final repository = UserRepository();
 ref.onDispose(() => repository.close());
 return repository.getUsers();
});

.autoDisposeをつけることで自動的に参照されなくなったProviderは破棄されるようになる。また、ref.onDispose()で破棄されるタイミングでの処理を書くことが出来る。基本的にはautoDisposeをつけておいて、自動的に破棄されると困る所は注意しつつ使うと良いかもしれない。

最後に

基本的な使い方は公式サイトを見ればだいたい分かる。しかし、実際のアプリケーションに組み込んだ時には、どのProviderをどの様に使うべきなのかは正直迷うと思う。とりあえずこんな感じで実装しとくと良いよ、的なガイドラインがあると嬉しい所。

https://zenn.dev/umatoma/books/bd010486772aff