😺

【Riverpod】大規模Flutterプロジェクトでの StateProvider 乱立を解消するベストプラクティス

2024/12/29に公開

Riverpod は強力な状態管理パッケージですが、大規模プロジェクトで StateProvider を多用すると、

  • どこからでも状態を更新できてしまう
  • 依存関係やライフサイクルがあいまいになりやすい
  • デバッグが困難になる

といった問題が起きやすくなります。

ここでは、これらの問題を回避・軽減するベストプラクティスを複数の実例を交えながら紹介します。
「StateProvider を使うべき場面」と「より適切な方法」 を見極め、プロジェクトをスケールさせる際の指針にしてみてください。


1. 大前提:「責務の分割」 + 「グローバルな状態の乱立を避ける」

まず前提として「すべてを StateProvider で持たない」ことが重要です。
Riverpod では

  1. StateProvider
  2. StateNotifierProvider / NotifierProvider
  3. ChangeNotifierProvider
  4. FutureProvider / StreamProvider
  5. Provider (読み取り専用)

など、さまざまな Provider が存在します。
アプリの状態の性質に応じて適切な Provider を選択しないと、デバッグしづらいコードが増えていきます。

1.1. StateProvider を使いすぎると何が問題か?

  • 誰でも直接 state を更新できる
    どの場所からでも ref.read(XXXProvider.notifier).state = ...; のように更新できるため、予期しない箇所で値が書き換えられ、バグを引き起こしやすい。
  • 同時並行で変更が行われた場合の競合
    大きなアプリで複数箇所から同じ StateProvider を更新していると、どの更新が最後に適用されるかが混乱の原因になる。
  • ログや変更履歴のトラッキングがしにくい
    StateProvider はステート管理がシンプルな反面、更新の履歴を簡単に追跡する仕組みがありません。

2. ベストプラクティス

2.1. まずは StateNotifierProviderNotifierProvider への移行を検討

■ 理由: ロジックの集中管理

StateNotifier / Notifier を使うと、

  • 値の変更ロジックを StateNotifier(クラス)に集約
  • state を更新するメソッドを 1 カ所にまとめられる
  • カスタムメソッドを用意して状態遷移を明示的に書ける
  • 更新時にログ出力やバリデーションがしやすい
  • Immutable な状態管理( copyWith ) を取り入れやすい

などのメリットが得られます。
「誰がいつ、何のために状態を更新しているのか」がコードとして明確になり、デバッグしやすくなります。

[ 実例: StateNotifier を使うカウンター ]

// 例: カウンター状態を管理する State
class CounterState {
  final int value;
  CounterState(this.value);

  // イミュータブルに扱いたい場合は copyWith を使う
  CounterState copyWith({int? value}) {
    return CounterState(value ?? this.value);
  }
}

// 例: StateNotifier
class CounterNotifier extends StateNotifier<CounterState> {
  CounterNotifier() : super(CounterState(0));

  // increment メソッドにより、どのようにカウントアップするかを定義
  void increment() {
    // 状態変更を一箇所に集約
    state = state.copyWith(value: state.value + 1);
  }

  // reset メソッド
  void reset() {
    state = CounterState(0);
  }
}

// 例: Provider の定義
final counterProvider =
    StateNotifierProvider<CounterNotifier, CounterState>((ref) {
  return CounterNotifier();
});

使用側:

// increment ボタン
ElevatedButton(
  onPressed: () {
    // ref を使って increment メソッドを呼ぶ
    ref.read(counterProvider.notifier).increment();
  },
  child: Text("Increment"),
),
  • こうすることで、カウンター値を更新するのは increment() メソッドのみ。
  • もし増加量を変更したい、何かしらのトリガーで自動リセットしたいなどの場合は、この CounterNotifier クラスを修正すればよい。
  • StateProvider のように直接 state = newValue と書くよりも、変更の「意図」をコードで明確に示せるのが利点です。

■ 大規模プロジェクトではほぼ必須

特にビジネスロジックや状態遷移が複雑になるほど、StateNotifierProvider / NotifierProvider でロジックをまとめたほうが、可読性・保守性が圧倒的に向上します。
「StateProvider をとりあえず乱立」は避け、ドメインごとに Notifier クラスを持たせるのがオススメです。


2.2. Provider Observer でログを残す

Riverpod にはプロバイダの状態変更をフックできる ProviderObserver 機能があります。
これを使うと、「どのプロバイダが、いつ、どのように状態を変えたか」をコンソールなどにロギングできます。

[ 実例: Custom ProviderObserver ]

class MyProviderObserver extends ProviderObserver {
  
  void didUpdateProvider(
    ProviderBase provider,
    Object? previousValue,
    Object? newValue,
    ProviderContainer container,
  ) {
    // ログを出力
    debugPrint('''
Provider: ${provider.name ?? provider.runtimeType}
Previous Value: $previousValue
New Value: $newValue
''');
  }
}

使用方法:

void main() {
  runApp(
    ProviderScope(
      observers: [MyProviderObserver()],
      child: MyApp(),
    ),
  );
}
  • こうすると、StateProvider であっても更新のたびにコンソールにログを出せます。
  • 大量に StateProvider を使っていても、どのタイミングで値が変わったかを把握しやすくなるので、デバッグがしやすくなります。

■ ログが多すぎる場合

StateProvider が大量にあると、ログが氾濫する場合もあります。そのときは「特定の Provider のみログを出したい」など、フィルタリングのロジックを didUpdateProvider 内に実装するか、開発時だけ有効にするなど運用でカバーできます。


2.3. データモデル(Immutable) + copyWith で破壊的更新を防ぐ

StateProvider は、プリミティブな値 (int, Stringなど) や、ミュータブルなオブジェクトをそのまま持つことが可能です。すると、思わぬところで値が変わり、変更検知が破綻するケースが起こりえます。

■ 推奨: immutable クラスを使う


class User {
  final String id;
  final String name;

  const User({required this.id, required this.name});

  User copyWith({String? id, String? name}) {
    return User(
      id: id ?? this.id,
      name: name ?? this.name,
    );
  }
}

StateProvider<User> などで状態を保持するときも、

final userProvider = StateProvider<User>((ref) {
  return const User(id: '0', name: 'Default');
});

こうしておけば、更新時は

ref.read(userProvider.notifier).state = 
  ref.read(userProvider.notifier).state.copyWith(name: 'NewName');

のように新しいインスタンスを作成し、UI への通知を正しく行えます。
ミュータブルな Map や List を直接操作しないようにし、状態を変更するときは必ず copyWith を使うように徹底します。

補足: StateNotifier / Notifier で管理するときも同様に、状態は Immutable クラスとして持つと安全です。


2.4. ユースケース・ドメインごとにファイル/ディレクトリを分割

「大規模プロジェクトで StateProvider が乱立する」最大の理由は、1 ファイルに大量のプロバイダが定義されていたり、どこに何があるか不明瞭になってしまっているケースです。

■ ディレクトリ構成の一例

lib/
 ┣ features/
 ┃   ┣ auth/
 ┃   ┃   ┣ presentation/
 ┃   ┃   ┣ data/
 ┃   ┃   ┗ provider/
 ┃   ┃       ┣ auth_state_notifier.dart
 ┃   ┃       ┣ auth_state.dart
 ┃   ┃       ┗ auth_provider.dart
 ┃   ┣ todo/
 ┃   ┃   ┣ presentation/
 ┃   ┃   ┣ data/
 ┃   ┃   ┗ provider/
 ┃   ┃       ┣ todo_notifier.dart
 ┃   ┃       ┣ todo_state.dart
 ┃   ┃       ┗ todo_provider.dart
 ┗ common/...
  • ドメイン (機能) ごと に Provider, Notifier, State を分割。
  • 汎用的な機能 (ネットワーククライアント、ロギング等) は common/core/ ディレクトリにまとめる。
  • アプリ全体で使う「グローバルプロバイダ」は必要最小限に留める。

これによって、「あの状態はどこで管理しているのか?」 をすぐに探せるようになり、衝突や意図しない更新を防ぎやすくなります。


2.5. UI 側でのロジック混在を減らす(MVVM/MVCアーキテクチャ的手法)

Flutter は Widget をコードで組み立てるため、つい build メソッド内で StateProvider の状態を直接いじってしまいがちです。しかし大規模になるほど「画面ロジック」と「ビジネスロジック」を分割する方が保守しやすくなります。

■ 実例:ボタン押下時のロジックを Widget 側から隠蔽

// Bad Example
// ボタンのonPressed内にビジネスロジックが散在
ElevatedButton(
  onPressed: () async {
    final userState = ref.read(userProvider.notifier).state;
    // ここでAPIコール、エラーハンドリング、状態更新など
    if (userState.name.isNotEmpty) {
      // ...
    }
  },
  child: Text("Update"),
);
// Good Example
// ボタン押下時はビジネスメソッドだけを呼ぶ
ElevatedButton(
  onPressed: () => ref.read(userNotifierProvider.notifier).updateUserName(),
  child: Text("Update"),
);

StateNotifier / Notifier のメソッド内で API 呼び出し・エラーハンドリング・状態更新を集中管理すれば、Widget は UI とイベント登録だけに専念できるようになります。
こうすると、StateProvider を直接いじる機会が減り、ロジックを把握しやすくなります。


2.6. テストを書いてバグを早期発見

StateProvider / StateNotifierProvider などを使う際、UnitテストWidgetテストを導入することで、意図しない状態遷移があったときに早期に検知できます。

  • Notifier や StateNotifier は単体テストが書きやすい
  • ProviderContainer を使ったテストで Provider の動作をシミュレーションできる
  • UI に依存しないビジネスロジック部分をテストできれば、デバッグが飛躍的に楽になる

[ 実例: StateNotifier のテスト ]

void main() {
  test('CounterNotifier increments value', () {
    final container = ProviderContainer();
    final notifier = container.read(counterProvider.notifier);

    expect(container.read(counterProvider).value, 0);

    notifier.increment();
    expect(container.read(counterProvider).value, 1);

    notifier.reset();
    expect(container.read(counterProvider).value, 0);
  });
}
  • こうしてテストを書いておけば、カウンターの変更が意図どおりか検証できます。
  • 大規模プロジェクトで大量の状態更新があるからこそ、テストで網羅する価値が高いです。

3. まとめ

大規模 Flutter プロジェクトで StateProvider が乱立すると、

  • 「どこで・いつ・なぜ」更新されたか不明瞭
  • 競合やバグの温床
  • デバッグ困難

といった問題が発生しがちです。

対策の要点

  1. StateProvider を安易に大量定義しない
    • 「値を直接書き換える必要があるか?」を必ず検討
    • より適した StateNotifierProvider / NotifierProvider や Immutable データモデルで集中管理する。
  2. ロジックを Notifier クラスに集約
    • バリデーションやバグを仕込む余地を減らす。
    • デバッグ / テスト / コードリーディングが楽になる。
  3. ProviderObserver などでログ・変更履歴を把握
    • 変更が多発する場合はログに出力して原因を追いやすくする。
  4. ディレクトリやファイル構成を整理
    • ドメインごとに State / Notifier / Provider を明確に分割。
  5. UI 側にビジネスロジックを書かない
    • ボタン押下時などに直接 state = ... としない。
    • Notifier のメソッドを呼び出すだけの実装に留める。
  6. 単体テスト / Widgetテストでバグを早期発見
    • 特に複雑な状態遷移をテストでカバーすることで安心して変更できる。

これらを実践するだけでも、「StateProvider だらけでどこで更新されているか分からない…」 といった状況を大幅に改善できます。
Riverpod は柔軟性が高い分、アーキテクチャの設計に注意が必要ですが、しっかり設計すると大規模開発でも非常に快適に使えます。ぜひ参考にしてみてください。

Discussion