effidevFlutter・Cloudflareエッジ・クラウドコスト最適化

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

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

Flutterの状態管理ライブラリを選ぶ際によく目にする資料は、「Riverpodはコンパイル時安全性に優れ、Blocはイベント駆動なのでテストしやすい」といった機能比較表だ。間違ってはいないが、実際にプロダクションでチームを苦しめるのは機能一覧ではなく、半年〜1年経って初めて表面化する運用コストである。この記事はどちらのライブラリが優れているかを判定するものではない。代わりに、それぞれのライブラリが実務で実際に引き起こす問題をまず提示し、チーム規模やドメインの特性に応じて、いつどちらを選び、いつマイグレーションする価値があるかを判断するための基準を示す。

要点まとめ

  • Riverpodの本当のリスクは機能不足ではなく、providerスコープのミスによるstale stateである。特にshowDialogshowModalBottomSheetrootNavigator: trueのような経路はProviderScopeのoverrideツリーの外に出てしまい、想定と異なるインスタンスを読み込む。
  • Riverpodのcodegen(@riverpod + build_runner)はコラボレーションの摩擦を生む。 .g.dartファイルがdiffのノイズを増やし、新規メンバーはビルドが赤い状態からスタートすることが多い。
  • Blocの本当のリスクはイベントクラスの増殖(event explosion)である。 イベントがドメイン言語のように積み上がっていくと、新しい開発者が既存のイベントを異なる意味の箇所で再利用してしまい、副作用が絡み合う。
  • チーム規模1〜3人のMVPではRiverpod側の摩擦が小さく、複数のスクワッドが契約のように状態をやり取りする大規模組織や、イベントソーシングが必要な金融/コマースドメインでは、Blocの明示的な構造が有利になる。
  • 2026年7月時点の安定版はriverpod 3.3.2、riverpod_generator 4.0.4、flutter_bloc 9.1.1、bloc_test 10.0.0、provider 6.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に感じられる。

対応の原則は明確だ。

build_runnerが生むコラボレーションの摩擦とノイズだらけのdiff

riverpod_generatorを使うと、@riverpodアノテーションが付いたクラス/関数ごとにpart 'x.g.dart';が必要になり、コードを保存するたびにdart run build_runner watch --delete-conflicting-outputsがバックグラウンドで動いていないとIDEが赤線だらけになる。これがチーム単位で生む摩擦は3つある。

緩和策はある。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回記録される。イベント名はドメイン言語のように見えるが、実際にはハンドラに偶発的に結合された副作用を隠していたのだ。

この問題はコードの品質が低いから起きるのではなく、イベントの命名とハンドラの責務が分離されていないから起きる。チームが取れる実質的な緩和策は次の通りだ。

チーム規模・ドメイン別 選定基準表

チーム/ドメイン 推奨 理由
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へ移行する流れは、おおむね次の順序をたどる。

  1. ルートのラッピング置き換え: runApp(MultiProvider(providers: [...], child: MyApp()))runApp(ProviderScope(child: MyApp()))に変える。この時点では、既存のChangeNotifierProviderをRiverpodの互換レイヤーで包んで並行運用する方式のほうがリスクが小さい。
  2. ウィジェット型の変換: context.watch<T>() / context.read<T>()の呼び出し箇所をref.watch(xProvider) / ref.read(xProvider)に変えるには、StatelessWidget/StatefulWidgetConsumerWidget/ConsumerStatefulWidgetに変換する必要がある。機械的な作業だがdiffが大きくなるため、機能単位で分割し、レビュー可能なサイズにPRを分けるのが実務では有効だ。
  3. スコープの再設計: MultiProviderのネスト構造(タブごと、リストアイテムごとのスコープ)は、Riverpodの.family + .autoDisposeの組み合わせで再設計する必要がある。ここで、先に説明したautoDisposeの誤用によるstale state問題が、実際に最も多く報告されるイシューだ。チュートリアルをそのまま真似てセッション状態にまでautoDisposeをかけてしまい、ログイン状態がリセットされる事故がよく起きる。
  4. テストの書き直し: ChangeNotifierProvider<T>.valueでウィジェットを包んでいたテストは、ProviderScope(overrides: [...])で包み直す必要がある。ウィジェットテストが数百個規模のアプリでは、この部分がマイグレーションdiffの中で最も多くの行数を占める。共通のtestProviderScope()ヘルパーをあらかじめ用意しておけば、繰り返し作業を減らせる。
  5. 段階的な移行: ビッグバン方式の書き直しよりも、画面単位で1つずつ、共通のルートProviderScopeの下で新規画面はRiverpod、未移行の画面は既存のproviderのまま、当面共存させる方式のほうが実務ではリスクを管理しやすい。両者は同じウィジェットツリーの中で共存しても衝突しないため、無理に一度で終わらせる必要はない。

テストのしやすさ比較: provider override vs bloc_test

RiverpodはProviderContaineroverridesを渡す方式で依存関係を差し替える。

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はテストにおいてもスコープの概念をそのまま持ち込むため、プロダクションで経験するスコープのミスをテストでも同じように経験しうるという点を、Blocはイベントシーケンスの検証は容易だが依存性注入のインフラを別途整備する必要があるという点を、それぞれ踏まえておく必要がある。

結論: コードベース規模とチーム成熟度に基づく意思決定チェックリスト

以下の項目に「はい」が多いほど、そのライブラリ側の摩擦を引き受ける準備ができているということだ。

Riverpodが適している可能性が高いケース

Blocが適している可能性が高いケース

どちらを選ぶにせよ、ライブラリの選択そのものより、そのライブラリが強制しない規律をチーム自身が作れるかどうかが、実際のプロダクションでの成否を分ける。Riverpodはスコープの規律を、Blocはイベントガバナンスの規律を、チームが自ら打ち立てる必要がある。この規律を築く余力と意志があるほうを基準に選ぶことこそ、機能比較表よりもはるかに正確な意思決定の方法である。