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

**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. まずは `StateNotifierProvider` か `NotifierProvider` への移行を検討

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

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

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

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

```dart
// 例: カウンター状態を管理する 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();
});
```

**使用側:**

```dart
// 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 \]

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

**使用方法:**

```dart
void main() {
  runApp(
    ProviderScope(
      observers: [MyProviderObserver()],
      child: MyApp(),
    ),
  );
}
```

- こうすると、**`StateProvider` であっても**更新のたびにコンソールにログを出せます。  
- 大量に StateProvider を使っていても、どのタイミングで値が変わったかを把握しやすくなるので、デバッグがしやすくなります。

### ■ ログが多すぎる場合

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

---

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

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

### ■ 推奨: immutable クラスを使う

```dart
@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>` などで状態を保持するときも、  
```dart
final userProvider = StateProvider<User>((ref) {
  return const User(id: '0', name: 'Default');
});
```

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

```dart
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 側から隠蔽

```dart
// Bad Example
// ボタンのonPressed内にビジネスロジックが散在
ElevatedButton(
  onPressed: () async {
    final userState = ref.read(userProvider.notifier).state;
    // ここでAPIコール、エラーハンドリング、状態更新など
    if (userState.name.isNotEmpty) {
      // ...
    }
  },
  child: Text("Update"),
);
```

```dart
// 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 のテスト \]

```dart
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 は柔軟性が高い分、アーキテクチャの設計に注意が必要ですが、しっかり設計すると大規模開発でも非常に快適に使えます。ぜひ参考にしてみてください。