Riverpod vs Bloc、本番環境で後悔しないために: チーム規模別 実践的意思決定ガイド

Flutterの状態管理ライブラリを選ぶ際によく目にする資料は、「Riverpodはコンパイル時安全性に優れ、Blocはイベント駆動なのでテストしやすい」といった機能比較表だ。間違ってはいないが、実際にプロダクションでチームを苦しめるのは機能一覧ではなく、半年〜1年経って初めて表面化する運用コストである。この記事はどちらのライブラリが優れているかを判定するものではない。代わりに、それぞれのライブラリが実務で実際に引き起こす問題をまず提示し、チーム規模やドメインの特性に応じて、いつどちらを選び、いつマイグレーションする価値があるかを判断するための基準を示す。
要点まとめ
- Riverpodの本当のリスクは機能不足ではなく、providerスコープのミスによるstale stateである。特に
showDialog、showModalBottomSheet、rootNavigator: trueのような経路はProviderScopeのoverrideツリーの外に出てしまい、想定と異なるインスタンスを読み込む。- Riverpodのcodegen(
@riverpod+ build_runner)はコラボレーションの摩擦を生む。.g.dartファイルがdiffのノイズを増やし、新規メンバーはビルドが赤い状態からスタートすることが多い。- Blocの本当のリスクはイベントクラスの増殖(event explosion)である。 イベントがドメイン言語のように積み上がっていくと、新しい開発者が既存のイベントを異なる意味の箇所で再利用してしまい、副作用が絡み合う。
- チーム規模1〜3人のMVPではRiverpod側の摩擦が小さく、複数のスクワッドが契約のように状態をやり取りする大規模組織や、イベントソーシングが必要な金融/コマースドメインでは、Blocの明示的な構造が有利になる。
- 2026年7月時点の安定版は
riverpod3.3.2、riverpod_generator4.0.4、flutter_bloc9.1.1、bloc_test10.0.0、provider6.1.5+1である(pub.dev確認)。
なぜ機能比較表は間違った問いなのか
RiverpodもBlocも、「テスト可能性」「DIサポート」「コンパイル時安全性」といった項目では高いスコアを得る。実際どちらもプロダクションで問題なく動作するライブラリだ。問題は、この比較表が導入時点の学習コストしか示しておらず、チームが拡大しコードベースが積み上がったときに何が摩擦を生むのかを教えてくれない点にある。実際にIssueトラッカーやコミュニティで繰り返し報告される痛みは、大きく2つの軸に分かれる。Riverpodは「スコープの扱いを誤って状態がずれる」という問題、Blocは「イベントが増え続け、チームがイベントの意味をコントロールできなくなる」という問題だ。
Riverpodが本番環境で実際に問題を起こすポイント
providerスコープのミスによるstale state
Riverpodのスコープモデルは強力だが、直感を裏切る箇所がある。まずはよくある失敗パターンから見てみよう。
// 주문 상세 화면에서만 유효한 orderId를 스코프에 주입
class OrderDetailPage extends ConsumerWidget {
const OrderDetailPage({required this.orderId, super.key});
final String orderId;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ProviderScope(
overrides: [
currentOrderIdProvider.overrideWithValue(orderId),
],
child: const _OrderDetailBody(),
);
}
}
ここまでは正常だ。問題は_OrderDetailBodyの内部でshowDialog(context: context, ...)やshowModalBottomSheetを呼び出したときに起きる。これらのウィジェットはほとんどの場合、ルートNavigatorのOverlayにアタッチされる。 つまりProviderScopeのサブツリーの外にレンダリングされるため、ダイアログの中でref.watch(currentOrderIdProvider)を呼び出すと、overrideが適用されていない**グローバルなデフォルト値(または前の画面に残った値)**を読んでしまう。画面はA注文を表示しているのにダイアログはB注文のデータを表示するという、再現がきわめて困難なバグはこうして生まれる。
2つ目のよくある原因はautoDisposeの誤用だ。チュートリアルに従って習慣的にすべてのproviderに.autoDisposeを付けると、画面遷移中に最後のリスナーが消えた瞬間、providerが即座に破棄される。ユーザーセッションのように複数の画面にまたがって生き続けるべき状態にまでautoDisposeがかかっていると、戻ってきた瞬間にログイン状態やカート内容がリセットされたように見える。実際には「古くなった値」ではなく「再生成された初期値」なのだが、ユーザーから見ればどちらもstale stateに感じられる。
対応の原則は明確だ。
- 画面専用の一時的な状態にのみ
.autoDisposeを付け、セッション/アプリスコープの状態には明示的にref.keepAlive()を呼び出すか、そもそもautoDisposeを付けない。 - ダイアログ・ボトムシート・別のNavigatorを使うウィジェットは、必要な値を
ConsumerWidgetのビルド時点であらかじめ読み取ってパラメータとして渡すか、ルートのProviderScopeでoverrideして、どのツリーから読んでも同じインスタンスが見えるようにする。 - コードレビューのチェックリストに、「このoverrideが実際にカバーするサブツリーはどこまでか」を明示的に問う項目を入れる。
build_runnerが生むコラボレーションの摩擦とノイズだらけのdiff
riverpod_generatorを使うと、@riverpodアノテーションが付いたクラス/関数ごとにpart 'x.g.dart';が必要になり、コードを保存するたびにdart run build_runner watch --delete-conflicting-outputsがバックグラウンドで動いていないとIDEが赤線だらけになる。これがチーム単位で生む摩擦は3つある。
- オンボーディングの摩擦: 新規メンバーがリポジトリをクローンして
flutter pub getだけ実行すると、プロジェクトが真っ赤なエラーだらけになる。build_runnerの実行手順はオンボーディング文書から漏れやすく、漏れると「コードを何も触っていないのになぜコンパイルできないのか」という質問が繰り返される。 - diffのノイズ:
.g.dartをリポジトリにコミットするチームの場合、providerのシグネチャを1つ変えただけで、生成ファイルの数十〜数百行が同時に変わる。レビュアーが実際のロジック変更と生成コードの変更を見分けるのに認知コストがかかる。 - CI時間の増加:
.g.dartをコミットしないチームの場合、CIパイプラインごとにcodegenを回し直す必要があり、モノレポの規模が大きくなるほど増分ビルド時間が体感できるレベルで伸びていく。
緩和策はある。codegenなしでも、素のProvider/NotifierProvider/StateNotifierProvider構文だけでRiverpodは十分に使える。チームがcodegenの利便性(自動的な.family/.autoDispose推論、ボイラープレートの削減)を活かせるほど成熟していないなら、最初はnon-codegenスタイルで始め、コンベンションが定着してから移行するのが、コラボレーションの摩擦を減らす現実的な選択だ。
Blocのよくある落とし穴: イベントクラスの増殖(event explosion)
Blocは「イベント → Bloc → 状態」という明示的なフローを強制する。この明示性は初期段階では利点だが、機能が増えるほどイベントクラスが指数関数的に膨れ上がる。
abstract class CartEvent extends Equatable {
const CartEvent();
@override
List<Object?> get props => [];
}
class CartItemAdded extends CartEvent { /* ... */ }
class CartItemAddedFromRecommendation extends CartEvent { /* ... */ } // 분석 필드만 다름
class CartItemQuantityIncreased extends CartEvent { /* ... */ }
class CartItemQuantityDecreased extends CartEvent { /* ... */ }
class CartItemQuantityChangedManually extends CartEvent { /* ... */ } // 디바운스 타이머 트리거
class CartCouponApplied extends CartEvent { /* ... */ }
class CartCouponAppliedFromDeepLink extends CartEvent { /* ... */ } // 부수효과가 다름
ここで実際に起きる事故はこうだ。新しい開発者が「カート全体の数量一括変更」機能を追加する際に、既存のCartItemQuantityChangedManuallyハンドラを再利用する。名前だけを見れば「数量を変更するイベント」なので問題なさそうに見えるが、実際のハンドラの内部には手動入力時にのみ必要なデバウンスタイマーと分析ロギングが付随していた。その結果、一括変更時にはアイテム数と同じ数だけデバウンスタイマーが同時に生成され、分析サーバーにはユーザーがやってもいない「手動修正」イベントがN回記録される。イベント名はドメイン言語のように見えるが、実際にはハンドラに偶発的に結合された副作用を隠していたのだ。
この問題はコードの品質が低いから起きるのではなく、イベントの命名とハンドラの責務が分離されていないから起きる。チームが取れる実質的な緩和策は次の通りだ。
- イベントクラスには純粋に「何が起きたか」だけを持たせ、デバウンスや分析ロギングのような副作用は別のミドルウェア的な層(
bloc_concurrencyのイベントトランスフォーマー、あるいはリポジトリ層)に分離する。 - 機能が単に「1つの値を更新する」だけのレベルで、イベント履歴自体がドメイン上の価値を持たないなら、最初から
Blocの代わりにイベントクラスを持たないCubitを使う。監査ログやリプレイが不要な画面状態に対して、あえてイベント層を強制する理由はない。 - PRテンプレートに「このイベントを再利用せず、新しいイベントを作るべき理由」を問うチェックボックスを入れて、イベントの再利用が常にレビュアーの目に留まるようにする。
bloc_concurrencyパッケージのsequential()、droppable()、restartable()トランスフォーマーを積極的に活用し、「同じイベントが連打されたときに何が起きるか」をイベント定義ではなくトランスフォーマーの設定として明示する。
チーム規模・ドメイン別 選定基準表
| チーム/ドメイン | 推奨 | 理由 |
|---|---|---|
| 1〜3人のスタートアップMVP | Riverpod(non-codegen) | Event/State/Blocの三点セットを機能ごとに作るボイラープレートなしに、素早くピボットできる。codegenは後から導入しても遅くない |
| 4〜15人の成長期チーム | コンベンション文書化の余力次第 | Blocの明示的な契約はオンボーディングに有利だが、イベントガバナンス(再利用禁止ルール)を文書化・レビューで強制する余力が必要。余力が足りなければRiverpod + Notifierパターンの方が維持コストが低い |
| 大規模エンタープライズ(複数スクワッド) | Bloc | Event/Stateクラスがスクワッド間の契約として機能し、PRレビューでインターフェース変更が明確に見える。Riverpodの暗黙的な依存グラフはリントで強制しない限り、スクワッド間の結合が隠れやすい |
| イベントソーシングが必要な金融/コマース | Bloc | ドメインイベントがそのままBlocイベントに自然に対応し、イベントストリームをそのままイベントストアにappendしたり監査ログとして再利用できる。Riverpodは状態中心のパラダイムのため、イベントログを別途載せる必要がある |
この表は「この規模なら必ずこのライブラリ」ではなく、どちら側の摩擦を引き受ける余力があるかを問うツールとして使うべきだ。例えば大規模組織であっても、社内に強力なリント/アーキテクチャコンベンションチームがあり、Riverpodのスコープ誤用を静的解析で検出できるなら、Riverpodを押し通すのも合理的な選択だ。
実際のマイグレーション事例: Provider → Riverpodへの移行
providerパッケージ(現在6.1.5+1)で始めたアプリをRiverpodへ移行する流れは、おおむね次の順序をたどる。
- ルートのラッピング置き換え:
runApp(MultiProvider(providers: [...], child: MyApp()))をrunApp(ProviderScope(child: MyApp()))に変える。この時点では、既存のChangeNotifierProviderをRiverpodの互換レイヤーで包んで並行運用する方式のほうがリスクが小さい。 - ウィジェット型の変換:
context.watch<T>()/context.read<T>()の呼び出し箇所をref.watch(xProvider)/ref.read(xProvider)に変えるには、StatelessWidget/StatefulWidgetをConsumerWidget/ConsumerStatefulWidgetに変換する必要がある。機械的な作業だがdiffが大きくなるため、機能単位で分割し、レビュー可能なサイズにPRを分けるのが実務では有効だ。 - スコープの再設計:
MultiProviderのネスト構造(タブごと、リストアイテムごとのスコープ)は、Riverpodの.family+.autoDisposeの組み合わせで再設計する必要がある。ここで、先に説明したautoDisposeの誤用によるstale state問題が、実際に最も多く報告されるイシューだ。チュートリアルをそのまま真似てセッション状態にまでautoDisposeをかけてしまい、ログイン状態がリセットされる事故がよく起きる。 - テストの書き直し:
ChangeNotifierProvider<T>.valueでウィジェットを包んでいたテストは、ProviderScope(overrides: [...])で包み直す必要がある。ウィジェットテストが数百個規模のアプリでは、この部分がマイグレーションdiffの中で最も多くの行数を占める。共通のtestProviderScope()ヘルパーをあらかじめ用意しておけば、繰り返し作業を減らせる。 - 段階的な移行: ビッグバン方式の書き直しよりも、画面単位で1つずつ、共通のルート
ProviderScopeの下で新規画面はRiverpod、未移行の画面は既存のproviderのまま、当面共存させる方式のほうが実務ではリスクを管理しやすい。両者は同じウィジェットツリーの中で共存しても衝突しないため、無理に一度で終わらせる必要はない。
テストのしやすさ比較: provider override vs bloc_test
RiverpodはProviderContainerにoverridesを渡す方式で依存関係を差し替える。
test('주문 목록 로드 실패 시 error 상태를 노출한다', () async {
final container = ProviderContainer(
overrides: [
orderRepositoryProvider.overrideWithValue(
FakeOrderRepository()..throwsOnFetch = true,
),
],
);
addTearDown(container.dispose);
await expectLater(
container.read(orderListProvider.future),
throwsA(isException),
);
});
Blocはbloc_testパッケージのblocTestで、イベント-状態のシーケンスを宣言的に検証する。
blocTest<OrderBloc, OrderState>(
'fetch 실패 시 OrderError를 emit한다',
build: () {
when(() => repository.fetchOrders()).thenThrow(Exception('network'));
return OrderBloc(repository: repository);
},
act: (bloc) => bloc.add(OrderFetched()),
expect: () => [OrderLoading(), isA<OrderError>()],
);
両アプローチの実務上の違いは次の通りだ。
- Riverpod: provider自体が既にDIコンテナの役割を果たすため、別途モックライブラリを使わずとも
overrideWithValue/overrideWithだけでグラフ内の特定ノードを差し替えられる。ただし.family+.autoDisposeの組み合わせをテストで扱う際は、container.dispose()を忘れるとリソースリークの警告が出たり、override対象providerの正確なインスタンスを指定できずに「provider not found」系のエラーに遭遇しやすい。 - Bloc:
blocTestのact/expect/verify構造は、QAやコードレビュアーが「どのイベントが入るとどの状態が出るか」を一目で読み取りやすい。ただしBloc自体はDIの仕組みを提供しないため、リポジトリ層はmocktailのような別のモックライブラリとコンストラクタインジェクション(またはget_it)を組み合わせる必要がある。つまり「イベントシーケンスのテスト」と「依存関係のモック」が、それぞれ別のツールに分かれている。
結論として、Riverpodはテストにおいてもスコープの概念をそのまま持ち込むため、プロダクションで経験するスコープのミスをテストでも同じように経験しうるという点を、Blocはイベントシーケンスの検証は容易だが依存性注入のインフラを別途整備する必要があるという点を、それぞれ踏まえておく必要がある。
結論: コードベース規模とチーム成熟度に基づく意思決定チェックリスト
以下の項目に「はい」が多いほど、そのライブラリ側の摩擦を引き受ける準備ができているということだ。
Riverpodが適している可能性が高いケース
- 3人以下の小規模チームである、または機能スペックが頻繁に変わる初期MVP段階である。
-
ProviderScope/autoDisposeのスコープルールを文書化し、コードレビューで強制できる担当者がいる。 - codegenの導入可否をチームが自律的に決められ、
build_runnerのオンボーディング文書を維持管理する余力がある。 - ダイアログ・ボトムシートのようにNavigator Overlayを使う画面が少ない、またはそのパターンに対するスコープガイドが既に存在する。
Blocが適している可能性が高いケース
- 複数のスクワッドが同時に同じコードベースを触っており、状態変更の契約をPRレビューで明示したい。
- 金融/コマースのように、イベント自体が監査ログやリプレイ要件と直結している。
- 新規メンバーの流入が頻繁で、「このイベントを再利用してよいか」をレビュールールとして強制するプロセスが既にある、または作る計画がある。
- 単純な値の更新画面には
Cubitを使い、イベント履歴が実際にドメイン上の価値を持つ箇所にのみBlocを使うというように、両者を使い分ける規律がある。
どちらを選ぶにせよ、ライブラリの選択そのものより、そのライブラリが強制しない規律をチーム自身が作れるかどうかが、実際のプロダクションでの成否を分ける。Riverpodはスコープの規律を、Blocはイベントガバナンスの規律を、チームが自ら打ち立てる必要がある。この規律を築く余力と意志があるほうを基準に選ぶことこそ、機能比較表よりもはるかに正確な意思決定の方法である。