
こんにちは! 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(), ); } }
💡Lens(関数型プログラミングのLensに由来)は親子のStateを get/set で写像するペアで、wrapAction は子のActionを親のActionに変換する関数です。
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
UPSIDER Engineering Deckはこちら📣