UPSIDER Tech Blog

Riverpod v3 Generic Provider を状態管理に使ってみる

こんにちは! UPSIDER 支払い.com事業部のふっくです。 今回は Flutter の状態管理ライブラリ Riverpod の Generic Provider(What's new in Riverpod 3.0)を状態管理に使う方法を考えてみたので紹介します。 「親子Widget間の State/Action 伝搬をする」というユースケースになります。

Generic Providerでは型 = キー

Generic Providerは、同じ型引数であれば同じインスタンスを返します。

// Generic Provider の定義例
@riverpod
T myProvider<T>(Ref ref) => throw UnimplementedError();

final a = myProvider<int>();
final b = myProvider<int>();
assert(a == b); // true — 同じ型引数なので同じProvider

状態管理でGeneric Providerを使う

この「型 = Providerのキー」という性質を ProviderScope.overrides と組み合わせると、親子Widget間で State/Action を型安全に伝搬する構造が組めます。コア実装は約200行で、末尾に全文サンプルコードを用意しています。

Stateは親から子へ切り出され、Actionは子から親へ包まれて伝搬します。各層は xxxProvider.scope() でつなぎます。各層のWidgetは自分のState/Actionだけを知り、親の型には依存しません。

やっていることはMVI(Model-View-Intent)パターンであり、単方向データフローとなります。

3層のサンプルで紹介します。

こう書けます

この仕組みでは3つのGeneric Provider(stateGetterProvider, stateSetterProvider, actionSenderProvider)を使います。

class AppPage extends StatelessWidget {
  static final _lens = Lens<AppState, ParentState>(
    get: (appState) => appState.parent,
    set: (appState, parentState) => appState.copyWith(parent: parentState),
  );

  @override
  Widget build(BuildContext context) {
    return appStateProvider.scope(
      lens: _lens,
      wrapAction: AppAction.parent,
      child: const ParentPage(),
    );
  }
}

class ParentPage extends ConsumerWidget {
  static final _lens = Lens<ParentState, ChildState>(
    get: (parentState) => parentState.child,
    set: (parentState, childState) => parentState.copyWith(child: childState),
  );

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(store.parent.uiState);

    return Column(
      children: [
        Text('parentCount: ${state.parentCount}'),
        ElevatedButton(
          onPressed: () => ref.parent.send(const ParentAction.increment()),
          child: const Text('+1'),
        ),
        parentProvider.scope(
          lens: _lens,
          wrapAction: ParentAction.child,
          child: const ChildPage(),
        ),
      ],
    );
  }
}
class ChildPage extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final state = ref.watch(store.child.uiState);

    return ElevatedButton(
      onPressed: () => ref.child.send(const ChildAction.increment()),
      child: Text('childCount: ${state.childCount}'),
    );
  }
}

各ページのウィジェットは親のState/Actionを知らなくて良い作りになっています。

裏側の定義

先述の Lens の定義です。

@immutable
class Lens<A, B> {
  const Lens({required this.get, required this.set});

  final B Function(A) get;
  final A Function(A, B) set;
}

3層(App → Parent → Child)のState/Actionを定義します。FeatureState / FeatureAction はマーカーインターフェースです。Effectはone-shot event(SnackBar表示等)を扱うための仕組みです。

@freezed
abstract class AppState with _$AppState implements FeatureState {
  const factory AppState({
    @Default(ParentState()) ParentState parent,
  }) = _AppState;
}

@freezed
sealed class AppAction with _$AppAction implements FeatureAction {
  const factory AppAction.parent(ParentAction action) = Parent;
}

@freezed
abstract class ParentState with _$ParentState implements FeatureState {
  const factory ParentState({
    @Default(0) int parentCount,
    @Default(ChildState()) ChildState child,
  }) = _ParentState;
}

@freezed
sealed class ParentAction with _$ParentAction implements FeatureAction {
  const factory ParentAction.increment() = Increment;
  const factory ParentAction.child(ChildAction action) = Child;
}

// ChildState, ChildAction も同様に定義(サンプルコード全文を参照)

// Effect(one-shot event)
@freezed
sealed class ParentEffect with _$ParentEffect {
  const factory ParentEffect.none() = ParentNone;
  const factory ParentEffect.showMessage(String message) = ShowMessage;
}

Store はGeneric Providerへのアクセサをまとめる名前空間です。stateGetterProvider<ParentState>()store.parent.uiState と書けるようにします。本体は空で、各featureがextensionで追加する形です。

class Store {
  const Store._();
}

const store = Store._();

// 各featureが独立して追加
extension ParentStore on Store {
  StoreAccessor<ParentState, ParentEffect> get parent =>
      const StoreAccessor(noneEffect: const ParentEffect.none());
}

extension ChildStore on Store {
  StoreAccessor<ChildState, ChildEffect> get child =>
      const StoreAccessor(noneEffect: const ChildEffect.none());
}

Storeが状態へのアクセサなら、Actionの処理を担うのが FeatureMixin です。各Notifierがこのmixinを使います。send() を呼ぶと自層の onReceive() が実行され、その後 actionSenderProvider 経由で親へ伝搬します。

mixin FeatureMixin<State extends FeatureState, Action extends FeatureAction, Effect> on $Notifier<dynamic>
    implements FeatureContract<State, Action, Effect> {
...
    // FeatureMixin の Action 処理(抜粋)
    Future<void> send(Action action) async {
      await onReceive(action);  // 自層でハンドリング
      ref.read(actionSenderProvider<Action>()).onChange(action);  // 親へ伝搬
    }
    
    // 各Notifierが onReceive を実装する
    Future<void> onReceive(Action action);
...
}

ChildPageのボタンを押すと、Actionは各層のNotifierの onReceive() を経由しながら wrapAction で変換され、最上位まで伝搬します。

Stateも同様に、lens.set を通じて最上位まで伝搬します。

伝搬の接続を実現しているのが xxxProvider.scope() の中身です。内部では ProviderScope に3つのoverrideを渡しています。

ProviderScope(
  overrides: [
    // State↓: 親のStateからlens.getで子のStateを切り出す
    stateGetterProvider<ChildState>().overrideWith((ref) {
      return ref.watch(stateGetterProvider<ParentState>().select((s) => lens.get(s)));
    }),
    // State↑: 子のState更新をlens.setで親のStateに書き戻す
    stateSetterProvider<ChildState>().overrideWith((ref) {
      return ValueChangeHandler((childState) {
        final parentState = ref.read(stateGetterProvider<ParentState>());
        final newParentState = lens.set(parentState, childState);
        ref.read(stateSetterProvider<ParentState>()).onChange(newParentState);
      });
    }),
    // Action↑: 子のActionをwrapActionで親のActionに変換して送る
    actionSenderProvider<ChildAction>().overrideWith((ref) {
      return ValueChangeHandler((childAction) {
        ref.read(parentProvider.notifier).send(wrapAction(childAction));
      });
    }),
  ],
  child: child,
)

ネストするたびに各層の型でoverrideされ、任意の深さで動きます。

以上がState/Action伝搬の全体像です。

嬉しいこと

  • 各層は親を知らない: 各ページは自分のState/Actionだけで動きます。テスト時もその型の stateGetterProvider をoverrideするだけで、親のimportは不要です
  • 各層でActionをハンドリング: onReceive() で自層のActionを処理した上で親へ伝搬します。子のincrementを親層で検知してanalyticsを飛ばす、といった書き方が自然にできます
  • 任意の深さに対応: xxxProvider.scope() をネストするだけで層を追加できます
  • Riverpod既存APIだけ: ProviderScope.overrides × Generics だけで追加パッケージは不要です

サンプルコード全文

動作確認バージョン: flutter 3.41.2-stable / riverpod_annotation: 4.0.0 / riverpod_generator: 4.0.0+1 / hooks_riverpod: 3.1.0 / freezed: 3.2.3

freezed, riverpod_generatorで生成されたコードは含めていません。

コア実装(約200行)

    // ============================================================
    // state_provider.dart — Generic Provider
    // ============================================================
    
    @riverpod
    State stateGetter<State>(Ref ref) {
      throw UnimplementedError();
    }
    
    @riverpod
    ValueChangeHandler stateSetter<State>(Ref ref) {
      throw UnimplementedError();
    }
    
    @riverpod
    ValueChangeHandler actionSender<Action>(Ref ref) {
      throw UnimplementedError();
    }
    
    @immutable
    class ValueChangeHandler<T> {
      const ValueChangeHandler(this.onChange);
    
      final void Function(T) onChange;
    }
    
    @riverpod
    class EffectState<Effect> extends _$EffectState<Effect> {
      @override
      Effect build(Effect initial) {
        return initial;
      }
    
      void emit(Effect newEffect) {
        state = newEffect;
      }
    }
    
    // ============================================================
    // lens.dart — Lens / Setter
    // ============================================================
    
    @immutable
    class Lens<A, B> {
      const Lens({required this.get, required this.set});
    
      final B Function(A) get;
      final A Function(A, B) set;
    }
    
    @immutable
    class Setter<A, B> {
      const Setter(this.set);
      final A Function(A, B) set;
    }
    
    // ============================================================
    // feature_contract.dart
    // ============================================================
    
    abstract class FeatureContract<State extends FeatureState, Action extends FeatureAction, Effect> {
      void consume();
      Future<void> send(Action action);
    }
    
    abstract class FeatureState {}
    
    abstract class FeatureAction {}
    
    // ============================================================
    // store_accessor.dart
    // ============================================================
    
    class StoreAccessor<State, Effect> {
      const StoreAccessor({required this.noneEffect});
    
      final Effect noneEffect;
    
      StateGetterProvider<State> get uiState => stateGetterProvider<State>();
    
      EffectStateProvider<Effect> get effect => effectStateProvider<Effect>(noneEffect);
    }
    
    // ============================================================
    // store.dart
    // ============================================================
    
    class Store {
      const Store._();
    }
    
    const store = Store._();
    
    // ============================================================
    // feature_mixin.dart
    // ============================================================
    
    mixin FeatureMixin<State extends FeatureState, Action extends FeatureAction, Effect> on $Notifier<dynamic>
        implements FeatureContract<State, Action, Effect> {
      StoreAccessor<State, Effect> get accessor;
    
      // --- State ---
      @nonVirtual
      State get uiState => ref.read(accessor.uiState);
    
      @nonVirtual
      set uiState(State state) {
        ref.read(stateSetterProvider<State>()).onChange(state);
      }
    
      // --- Effect ---
      @nonVirtual
      set effect(Effect e) {
        ref.read(accessor.effect.notifier).emit(e);
      }
    
      @override
      @nonVirtual
      void consume() => effect = accessor.noneEffect;
    
      // --- Action ---
      @override
      @nonVirtual
      Future<void> send(Action action) async {
        await onReceive(action);
        ref.read(actionSenderProvider<Action>()).onChange(action);
      }
    
      @visibleForOverriding
      Future<void> onReceive(Action action);
    
      // --- Combine ---
      @nonVirtual
      void combineWith<T>(ProviderListenable<T> provider, {required Setter<State, T> converter}) {
        ref.listen(provider, (_, next) {
          Future.microtask(() {
            if (!ref.mounted) return;
            uiState = converter.set(uiState, next);
          });
        }, fireImmediately: true);
      }
    }
    
    // ============================================================
    // typed_scope.dart
    // ============================================================
    
    extension ProviderScopeExtension<ParentNotifier extends $Notifier<dynamic>>
        on $NotifierProvider<ParentNotifier, dynamic> {
      TypedScope<
        $NotifierProvider<ParentNotifier, dynamic>,
        ParentNotifier,
        ParentState,
        ParentAction,
        ChildState,
        ChildAction
      >
      scope<
        ParentState extends FeatureState,
        ParentAction extends FeatureAction,
        ChildState extends FeatureState,
        ChildAction extends FeatureAction
      >({
        required Lens<ParentState, ChildState> lens,
        required ParentAction Function(ChildAction) wrapAction,
        required Widget child,
        Key? key,
      }) {
        return TypedScope<
          $NotifierProvider<ParentNotifier, dynamic>,
          ParentNotifier,
          ParentState,
          ParentAction,
          ChildState,
          ChildAction
        >(key: key, parentProvider: this, lens: lens, wrapAction: wrapAction, child: child);
      }
    
      /// ChildStateがnon-nullの場合のみchildを表示する
      Widget scopeIf<
        ParentState extends FeatureState,
        ParentAction extends FeatureAction,
        ChildState extends FeatureState,
        ChildAction extends FeatureAction
      >({
        required Lens<ParentState, ChildState?> lens,
        required ParentAction Function(ChildAction) wrapAction,
        required Widget child,
        Key? key,
      }) {
        return Consumer(
          builder: (context, ref, _) {
            final childState = ref.watch(stateGetterProvider<ParentState>().select((s) => lens.get(s)));
            if (childState == null) {
              return const SizedBox.shrink();
            }
            return TypedScope<
              $NotifierProvider<ParentNotifier, dynamic>,
              ParentNotifier,
              ParentState,
              ParentAction,
              ChildState,
              ChildAction
            >(
              key: key,
              parentProvider: this,
              lens: Lens(
                get: (parentState) => lens.get(parentState)!,
                set: (parentState, childState) => lens.set(parentState, childState),
              ),
              wrapAction: wrapAction,
              child: child,
            );
          },
        );
      }
    }
    
    class TypedScope<
      ParentNotifierProvider extends $NotifierProvider<ParentNotifier, dynamic>,
      ParentNotifier extends $Notifier<dynamic>,
      ParentState extends FeatureState,
      ParentAction extends FeatureAction,
      ChildState extends FeatureState,
      ChildAction extends FeatureAction
    >
        extends ConsumerWidget {
      final ParentNotifierProvider parentProvider;
      final Lens<ParentState, ChildState> lens;
      final ParentAction Function(ChildAction) wrapAction;
      final Widget child;
    
      const TypedScope({
        super.key,
        required this.parentProvider,
        required this.lens,
        required this.wrapAction,
        required this.child,
      });
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        return ProviderScope(
          overrides: [
            // 親から子のStateを計算
            stateGetterProvider<ChildState>().overrideWith((ref) {
              return ref.watch(stateGetterProvider<ParentState>().select((s) => lens.get(s)));
            }),
            // 子から親のStateを更新
            stateSetterProvider<ChildState>().overrideWith((ref) {
              return ValueChangeHandler((childState) {
                final parentState = ref.read(stateGetterProvider<ParentState>());
                final newParentState = lens.set(parentState, childState);
                ref.read(stateSetterProvider<ParentState>()).onChange(newParentState);
              });
            }),
            // 子のActionを親のActionに変換して伝搬
            actionSenderProvider<ChildAction>().overrideWith((ref) {
              return ValueChangeHandler((childAction) {
                final parentAction = wrapAction(childAction);
                final parentNotifier = ref.read(parentProvider.notifier);
                if (parentNotifier is FeatureMixin<ParentState, ParentAction, dynamic>) {
                  parentNotifier.send(parentAction);
                } else {
                  // ルートではFeatureMixinを実装しないため、直接ActionSenderを送るだけにする
                  ref.read(actionSenderProvider<ParentAction>()).onChange(parentAction);
                }
              });
            }),
          ],
          child: child,
        );
      }
    }

3層サンプル実装

    // ============================================================
    // app_contract.dart
    // ============================================================
    
    @freezed
    abstract class AppState with _$AppState implements FeatureState {
      const factory AppState({
        @Default(ParentState()) ParentState parent,
      }) = _AppState;
    }
    
    @freezed
    sealed class AppAction with _$AppAction implements FeatureAction {
      const factory AppAction.parent(ParentAction action) = Parent;
    }
    
    // ============================================================
    // app_state_notifier.dart
    // ============================================================
    
    @riverpod
    class AppStateNotifier extends _$AppStateNotifier
        implements FeatureContract<AppState, AppAction, void> {
      @override
      AppState build() {
        return const AppState();
      }
    
      @override
      Future<void> send(AppAction action) async {
        debugPrint('Debug: AppAction received: $action');
      }
    
      void setState(AppState newState) {
        debugPrint('Debug: AppState changed: $newState');
        state = newState;
      }
    
      @override
      void consume() {}
    }
    
    // ============================================================
    // parent_contract.dart
    // ============================================================
    
    extension ParentStore on Store {
      StoreAccessor<ParentState, ParentEffect> get parent =>
          const StoreAccessor(noneEffect: const ParentEffect.none());
    }
    
    @freezed
    abstract class ParentState with _$ParentState implements FeatureState {
      const factory ParentState({
        @Default(0) int parentCount,
        @Default(ChildState()) ChildState child,
      }) = _ParentState;
    }
    
    @freezed
    sealed class ParentAction with _$ParentAction implements FeatureAction {
      const factory ParentAction.increment() = Increment;
      const factory ParentAction.child(ChildAction action) = Child;
    }
    
    @freezed
    sealed class ParentEffect with _$ParentEffect {
      const factory ParentEffect.none() = ParentNone;
      const factory ParentEffect.showMessage(String message) = ShowMessage;
    }
    
    // ============================================================
    // parent_notifier.dart
    // ============================================================
    
    extension ParentNotifierRef on WidgetRef {
      ParentNotifier get parent => read(parentProvider.notifier);
    }
    
    @riverpod
    class ParentNotifier extends _$ParentNotifier
        with FeatureMixin<ParentState, ParentAction, ParentEffect> {
      @override
      StoreAccessor<ParentState, ParentEffect> get accessor => store.parent;
    
      @override
      void build() {}
    
      @override
      Future<void> onReceive(ParentAction action) async {
        await action.when(
          increment: () async {
            uiState = uiState.copyWith(parentCount: uiState.parentCount + 1);
            effect = ParentEffect.showMessage('count: ${uiState.parentCount}');
          },
          child: (_) async {},
        );
      }
    }
    
    // ============================================================
    // child_contract.dart
    // ============================================================
    
    extension ChildStore on Store {
      StoreAccessor<ChildState, ChildEffect> get child =>
          const StoreAccessor(noneEffect: const ChildEffect.none());
    }
    
    @freezed
    abstract class ChildState with _$ChildState implements FeatureState {
      const factory ChildState({
        @Default(0) int childCount,
      }) = _ChildState;
    }
    
    @freezed
    sealed class ChildAction with _$ChildAction implements FeatureAction {
      const factory ChildAction.increment() = ChildIncrement;
    }
    
    @freezed
    sealed class ChildEffect with _$ChildEffect {
      const factory ChildEffect.none() = ChildNone;
    }
    
    // ============================================================
    // child_notifier.dart
    // ============================================================
    
    extension ChildNotifierRef on WidgetRef {
      ChildNotifier get child => read(childProvider.notifier);
    }
    
    @riverpod
    class ChildNotifier extends _$ChildNotifier
        with FeatureMixin<ChildState, ChildAction, ChildEffect> {
      @override
      StoreAccessor<ChildState, ChildEffect> get accessor => store.child;
    
      @override
      void build() {}
    
      @override
      Future<void> onReceive(ChildAction action) async {
        await action.when(
          increment: () async {
            uiState = uiState.copyWith(childCount: uiState.childCount + 1);
          },
        );
      }
    }
    
    // ============================================================
    // app_page.dart
    // ============================================================
    
    class AppPage extends StatelessWidget {
      const AppPage({super.key});
    
      static final _lens = Lens<AppState, ParentState>(
        get: (appState) => appState.parent,
        set: (appState, parentState) => appState.copyWith(parent: parentState),
      );
    
      @override
      Widget build(BuildContext context) {
        return appStateProvider.scope(
          lens: _lens,
          wrapAction: AppAction.parent,
          child: const ParentPage(),
        );
      }
    }
    
    // ============================================================
    // parent_page.dart
    // ============================================================
    
    class ParentPage extends ConsumerWidget {
      const ParentPage({super.key});
    
      static final _lens = Lens<ParentState, ChildState>(
        get: (parentState) => parentState.child,
        set: (parentState, childState) => parentState.copyWith(child: childState),
      );
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final state = ref.watch(store.parent.uiState);
    
        // Effectのハンドリング(one-shot event)
        ref.listen(store.parent.effect, (_, effect) {
          effect.when(
            none: () {},
            showMessage: (message) {
              ref.parent.consume();
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(message)),
              );
            },
          );
        });
    
        return Column(
          children: [
            Text('parentCount: ${state.parentCount}'),
            ElevatedButton(
              onPressed: () => ref.parent.send(const ParentAction.increment()),
              child: const Text('+1'),
            ),
            parentProvider.scope(
              lens: _lens,
              wrapAction: ParentAction.child,
              child: const ChildPage(),
            ),
          ],
        );
      }
    }
    
    // ============================================================
    // child_page.dart
    // ============================================================
    
    class ChildPage extends ConsumerWidget {
      const ChildPage({super.key});
    
      @override
      Widget build(BuildContext context, WidgetRef ref) {
        final state = ref.watch(store.child.uiState);
    
        return ElevatedButton(
          onPressed: () => ref.child.send(const ChildAction.increment()),
          child: Text('childCount: ${state.childCount}'),
        );
      }
    }
    
    // ============================================================
    // main.dart
    // ============================================================
    
    void main() {
      runApp(
        ProviderScope(
          overrides: [
            stateGetterProvider<AppState>().overrideWith((ref) {
              return ref.watch(appStateProvider);
            }),
            stateSetterProvider<AppState>().overrideWith((ref) {
              return ValueChangeHandler((appState) {
                ref.read(appStateProvider.notifier).setState(appState);
              });
            }),
            actionSenderProvider<AppAction>().overrideWith((ref) {
              return ValueChangeHandler((action) {
                ref.read(appStateProvider.notifier).send(action);
              });
            }),
          ],
          child: const MaterialApp(home: Scaffold(body: AppPage())),
        ),
      );
    }

あとがき

今回はRiverpod v3の新機能であるGeneric Providerのユースケースを一つ紹介しました。 実際に使ってみると、型安全性を保ちながらプロバイダーを柔軟に扱えるこの機能の強力さを実感できました。

今回紹介したのはあくまでも活用例の一つに過ぎません。アイデア次第でさまざまな場面に応用できる機能なので、ぜひ自分のプロジェクトで試しながら「ベストプラクティス」を見つけてみてください。この記事が、その第一歩のきっかけになれば幸いです。

We Are Hiring

herp.careers

herp.careers

UPSIDER Engineering Deckはこちら📣

speakerdeck.com