Lv.3 Riverpod
0 / 5
TOPIC 3-1

Provider基礎

グローバル変数の問題を解決する「状態管理の仕組み」を理解する

0%
STEP 1 / 5

グローバル変数の誘惑

ジムの全マシンに「使用中」ランプが1つだけのコントロールパネルで管理されている状態を想像してみよう。誰がいつ切り替えたか分からず、ランプの状態と実際のマシン使用状況がズレてしまう。

アプリの複数画面から使う「データベース」を、グローバル変数で管理するとこうなる:

// やってはいけない例
AppDatabase globalDb = AppDatabase();

// home_screen.dart から直接アクセス
final sessions = await globalDb.select(...).get();
// history_screen.dart からも直接アクセス
final history = await globalDb.select(...).get();

これの問題点:

  • どの画面がいつDBを使っているか追跡できない
  • テスト時に本番DBに接続してしまう
  • DBを差し替えたいとき、全ファイルを修正する必要がある
グローバル変数は「誰でもどこからでも読み書きできる」ため、アプリが大きくなると不具合の原因が追えなくなる。筋トレで言えば、記録用紙をジムの入口に1枚だけ置いて、全員が同時に書き込むようなもの。
STEP 2 / 5

Providerで「管理された共有」を実現する

筋トレメモの実際のコード:

lib/features/workout/workout_provider.dart
@Riverpod(keepAlive: true)
AppDatabase appDatabase(Ref ref) {
  return AppDatabase();
}
「appDatabase という Provider を定義する。呼ばれたら AppDatabase のインスタンスを1つ返す。keepAlive なのでアプリ終了まで生き続ける」

Provider は「データの受付窓口」。直接グローバル変数を触るのではなく、Provider を通じてアクセスする。

Providerグローバル変数
アクセス制御Riverpodが管理誰でも直接触れる
テストoverrideでモック注入可本番DBに接続
ライフサイクル自動管理(keepAlive/自動破棄)手動管理
変更通知自動で画面が再描画手動で setState が必要
STEP 3 / 5

依存グラフ: Providerの連鎖

筋トレメモでは、Provider が他の Provider を利用して「依存の連鎖」を形成している:

lib/features/workout/workout_provider.dart
// 1. DB本体(全ての土台)
@Riverpod(keepAlive: true)
AppDatabase appDatabase(Ref ref) {
  return AppDatabase();
}

// 2. Repository(DBを使う)
@riverpod
WorkoutRepository workoutRepository(Ref ref) {
  return WorkoutRepository(ref.watch(appDatabaseProvider));
}

依存グラフを図で表すと:

AppDatabase Provider
        |
        v
WorkoutRepository Provider
        |
        v
ActiveWorkoutNotifier (画面の状態管理)
        |
        v
HomeScreen / WorkoutScreen (UI)
これはジムの組織図と同じ。「設備(DB)」を「管理スタッフ(Repository)」が扱い、「トレーナー(Notifier)」が記録を管理し、「利用者(UI)」はトレーナーを通じてだけ記録にアクセスする。
なぜ直接DBを触らない? WorkoutRepository が「ドメインデータ(ActiveWorkoutState) と DB形式(WorkoutsCompanion) の変換」を担当しているから。UIがDB形式を知る必要がなくなる。
STEP 4 / 5

@Riverpod と @riverpod の違い

筋トレメモのコードをよく見ると、大文字と小文字の2種類がある:

@Riverpod(keepAlive: true)  // 大文字R + オプション指定
AppDatabase appDatabase(Ref ref) { ... }

@riverpod  // 小文字r(デフォルト設定)
WorkoutRepository workoutRepository(Ref ref) { ... }
@Riverpod(keepAlive: true)@riverpod
寿命アプリ終了まで生存参照がなくなると自動破棄
使い時DB接続など、常に必要なもの画面ごとのRepositoryなど
メモリずっと占有不要になったら解放
DBは全画面で共有するため keepAlive で常駐させる。Repository は画面遷移で不要になることがあるため、自動破棄させてメモリを節約する。
STEP 5 / 5

Provider vs setState: 判断基準

筋トレメモの App クラスでは setState を使っている箇所がある:

lib/app.dart
class _AppState extends ConsumerState<App> {
  int _selectedIndex = 0;  // setState で管理

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        bottomNavigationBar: NavigationBar(
          onDestinationSelected: (index) {
            setState(() => _selectedIndex = index);
          },
        ),
      ),
    );
  }
}

判断フローチャート:

この状態を「他のWidget」で使う?
  |
  +-- YES --> Provider を使う
  |           例: appDatabaseProvider(HomeScreen, HistoryScreen...)
  |           例: activeWorkoutProvider(WorkoutScreen, 完了画面...)
  |
  +-- NO  --> この Widget 内だけで完結する?
                |
                +-- YES --> setState で十分
                |           例: _selectedIndex(ナビバーのタブ切替)
                |
                +-- NO  --> Provider を使う
筋トレの「ベルトの使い分け」と同じ。軽い重量(1つのWidget内)ではベルト不要(setState)。高重量(複数Widget間で共有)では安全のためベルト必須(Provider)。
状態選択理由
_selectedIndexsetStateApp Widget内だけで使うタブ番号
ActiveWorkoutStateProviderWorkoutScreen, 完了画面, HomeScreenで共有
AppDatabaseProvider全Repository、全画面から参照
CalendarMonthProviderカレンダーと日詳細シートで共有
Q1. ref.watch(appDatabaseProvider) は何を返す?
Provider は「値の入れ物」。ref.watch() で取り出すと、Provider の build が返した値(ここでは AppDatabase())が得られる。
Q2. _selectedIndex を Provider ではなく setState で管理している理由は?
「複数Widgetで共有するか?」が判断基準。_selectedIndex はApp内のナビバー切り替えだけなので setState で十分。
Q3. @Riverpod(keepAlive: true)@riverpod の違いは?
keepAlive: true は「常に生存」。DB接続のように常時必要なProviderに使う。@riverpod(自動破棄)はメモリ効率が良いが、毎回再生成コストがかかる。

3-1 まとめ

  • Provider はグローバル変数の問題(追跡不能・テスト困難)を解決する「管理された共有」の仕組み
  • Provider 同士の依存グラフで DB → Repository → Notifier → UI のレイヤー構造を表現する
  • Provider vs setState の判断基準: 「複数Widgetで共有するか?」
TOPIC 3-2

ref.watch / read / listen

Provider の値を「いつ・どう取得するか」で使い分ける3つのメソッド

0%
STEP 1 / 5

ref.watch: 監視して自動再描画

lib/features/home/home_screen.dart
class HomeScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final routineListAsync = ref.watch(routineListProvider);

    return Scaffold(
      body: routineListAsync.when(
        loading: () => CircularProgressIndicator(),
        error: (e, _) => Text('エラー: $e'),
        data: (list) => ListView(...),
      ),
    );
  }
}
「ref.watch(routineListProvider) で、ルーティン一覧を監視する。データが変わるたびに build が再実行されて画面が更新される」
ジムの電光掲示板と同じ。掲示板(watch)を見ていれば「空きマシン情報」が自動更新される。いちいちスタッフに聞きに行く必要がない。

ref.watchbuild() メソッド内で使う。値が変わると自動で build() が再実行される。

STEP 2 / 5

ref.read: 1回だけ取得

lib/features/home/home_screen.dart
Future<void> _onRoutineTap(
  BuildContext context, WidgetRef ref,
  RoutineWithExercises data,
) async {
  await ref.read(activeWorkoutProvider.notifier).startSession(data);
  if (!context.mounted) return;
  Navigator.of(context).push(...);
}
「ref.read で ActiveWorkoutNotifier を1回だけ取得し、startSession を呼ぶ。ここでは監視する必要がない」
ref.watchref.read
用途値の変化を監視して再描画今この瞬間の値を1回取得
使う場所build()onTap / onPressed などのコールバック内
再実行値が変わると build が再実行しない(スナップショット)
なぜ onTap で watch を使わない? onTap はユーザーがタップした「瞬間だけ」実行される。その後もずっと監視し続ける必要がないから。逆に build 内で read を使うと、Provider の値が変わっても画面が更新されないバグになる。
STEP 3 / 5

build内 / コールバック内の使い分けルール

実際の HomeScreen から、watch と read の使い分けを整理する:

class HomeScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // build 内 = ref.watch(画面の描画に必要なデータ)
    final routineListAsync = ref.watch(routineListProvider);
    return Scaffold(...);
  }

  Future<void> _onRoutineTap(...) async {
    // コールバック内 = ref.read(アクションの実行)
    await ref.read(activeWorkoutProvider.notifier).startSession(data);
  }
}

覚え方:

  • build 内: 「見続ける」必要がある → ref.watch
  • コールバック内: 「やる」だけ → ref.read
ジムのモニターカメラ(watch)は常時映像を流し続ける。一方、体重計(read)は乗った瞬間の値だけ知ればいい。
STEP 4 / 5

ref.listen: 変化した瞬間に副作用を実行

ref.listen は「値が変わった瞬間に何かを実行する」ためのもの。watch のように再描画はせず、コールバックを呼ぶだけ。

// 例: セッション完了時にスナックバーを表示する
ref.listen(activeWorkoutProvider, (prev, next) {
  if (prev != null && next == null) {
    ScaffoldMessenger.of(context).showSnackBar(
      SnackBar(content: Text('セッション完了!')),
    );
  }
});
メソッド再描画?副作用?使う場所
ref.watchするしないbuild内
ref.readしないしないコールバック内
ref.listenしないするbuild内(で登録)
STEP 5 / 5

Provider中間層の役割: 飛ばすとどうなる?

筋トレメモの「ルーティン一覧表示」の流れを見てみよう:

lib/features/home/home_screen.dart → routine_provider.dart → workout_provider.dart
// 実際の構造(Provider中間層あり)
HomeScreen
  └─ ref.watch(routineListProvider)
       └─ routineListProvider
            └─ ref.watch(routineRepositoryProvider)
                 └─ routineRepositoryProvider
                      └─ ref.watch(appDatabaseProvider)

もし Provider を飛ばして直接 DB にアクセスしたら?

// NG: Provider を飛ばした例
class HomeScreen extends ConsumerWidget {
  Widget build(context, ref) {
    final db = ref.watch(appDatabaseProvider);
    // 画面内で直接SQL相当の操作...
    return FutureBuilder(
      future: db.select(db.routines).get(),
      ...
    );
  }
}

失われるもの:

  • PR判定ロジック: completeSession() 内の1RM計算と過去最高比較 — Repository/Notifier がないと UI に書くことに
  • サマリー計算: totalVolumetotalFilledSetCount などの集計 — 全画面で重複コードになる
  • 状態管理: セッション開始/セット更新/完了の一連の流れ — 各画面がバラバラに管理することに
  • テスト容易性: Repository を override してモックDBでテストする手段が失われる
Provider 中間層(Repository)は「UIが知る必要のないDB操作の詳細」をカプセル化している。これがなければ、UI にドメインロジック(PR判定等)とDB操作が混在し、修正のたびに全画面を触ることになる。
Q1. onTap コールバック内で ref.watch を使うとどうなる?
ref.watch は build メソッド内でのみ使用可能。コールバック内で使うと実行時エラーになる。コールバック内では ref.read を使う。
Q2. Repository Provider を飛ばして UI から直接 DB にアクセスすると、何が失われる?
Repository 層にはドメインロジック(PR判定、ボリューム計算)とDB形式変換が集約されている。これを飛ばすと、同じロジックを複数画面に重複して書くことになり、テスト時にDBを差し替えることもできなくなる。

3-2 まとめ

  • ref.watch = build内で監視 / ref.read = コールバックで1回取得 / ref.listen = 変化時に副作用
  • build内で read を使うと「画面が更新されない」バグ、コールバック内で watch を使うと実行時エラー
  • Provider 中間層(Repository)を飛ばすと、PR判定・サマリー計算・テスト容易性が失われる
TOPIC 3-3

Notifier パターン

複雑な状態を「メソッドで操作する」仕組みを ActiveWorkoutNotifier から学ぶ

0%
STEP 1 / 6

Notifier とは: 状態 + 操作メソッド

3-1で見た Provider は「値を返すだけ」だった。Notifier は「状態を持ち、メソッドで変更できる」Provider。

Provider(関数型)Notifier(クラス型)
状態変更できない(読み取り専用)メソッドで変更可能
用途DB、Repository などの「供給」セッション状態など「管理」
appDatabaseProviderActiveWorkoutNotifier
Provider(関数型) は自動販売機 — お金を入れると飲み物が出てくるだけ。Notifier は筋トレのパーソナルトレーナー — セッション開始、重量変更、セット追加、完了など、複数の操作を指示できる。
STEP 2 / 6

build() メソッド: 初期状態を決める

lib/features/workout/workout_provider.dart
@Riverpod(keepAlive: true)
class ActiveWorkoutNotifier extends _$ActiveWorkoutNotifier {
  @override
  ActiveWorkoutState? build() => null;
}
「ActiveWorkoutNotifier は ActiveWorkoutState? 型の状態を管理する。初期値は null(トレーニング未開始)」

ポイント:

  • build() は Notifier の「初期状態」を返すメソッド
  • 戻り値の型 (ActiveWorkoutState?) が、この Notifier が管理する state の型になる
  • null = 「セッション未開始」を表現(null safety の活用)
  • keepAlive: true = 画面遷移しても状態を保持(ワークアウト途中で画面を閉じても消えない)
なぜ keepAlive? セッション中にホーム画面に戻っても、記録途中のデータを保持するため。keepAlive がないと、画面遷移のたびに build() が再実行されて null に戻ってしまう。
STEP 3 / 6

state の更新: copyWith パターン

Notifier では state = ... で状態を更新する。イミュータブルなので、毎回新しいオブジェクトを作る:

lib/features/workout/workout_provider.dart
/// セットの重量を更新
void updateWeight(int exerciseIndex, int setIndex, double weight) {
  if (!_isValidSetIndex(exerciseIndex, setIndex)) return;
  final sets = state!.exercises[exerciseIndex].sets;
  final newSets = [
    for (var i = 0; i < sets.length; i++)
      if (i == setIndex) sets[i].copyWith(weight: weight) else sets[i],
  ];
  state = _withUpdatedSets(exerciseIndex, newSets);
}
「setIndex に一致するセットだけ weight を更新した新しいリストを作り、state に代入する」

state = 新しい値 を実行すると:

  1. Riverpod が「状態が変わった」ことを検知
  2. ref.watch している全Widget が自動で再描画される
なぜ直接 state!.exercises[0].sets[0].weight = 60 としないのか? freezed で不変(イミュータブル)にしているため、フィールドの直接変更はコンパイルエラーになる。copyWith で「一部だけ違う新しいオブジェクト」を作るのが正しいパターン。
STEP 4 / 6

入力パラメータ から 状態フィールド への変換

Notifier で最も重要なパターン: 外部から受け取ったデータを、内部の状態フィールドに変換する。

lib/features/workout/workout_provider.dart
Future<void> startSession(
  RoutineWithExercises data, {
  DateTime? targetDate,  // 入力パラメータ
}) async {
  state = ActiveWorkoutState(
    routineId: data.routine.id,
    routineName: data.routine.name,
    startedAt: targetDate ?? DateTime.now(),  // パラメータ → 状態フィールド
    exercises: data.exercises.map((e) {
      return ActiveExerciseData(
        exerciseId: e.id,
        name: e.name,
        sets: List.generate(defaultSetCount, (_) => const SetData()),
      );
    }).toList(),
  );
  ...
}

変換パターンの整理:

入力パラメータ変換状態フィールド
targetDate (DateTime?)?? DateTime.now()startedAt (DateTime)
data.exercises (List<Exercise>).map + デフォルト3セット生成exercises (List<ActiveExerciseData>)
data.routine.id (String)そのままroutineId (String?)
ジムの入会時の手続きと同じ。申込書(入力パラメータ)の「希望開始日: 未記入」を「開始日: 本日」に変換し、「選択コース: 3種目」から「トレーニングカード: 各種目3セット分の空欄」を生成する。
なぜ targetDate ?? DateTime.now()? 通常のセッション開始は「今この瞬間」が開始時刻。しかし「過去日付の記録追加」機能もあるため、nullable な targetDate で両方に対応する。null なら現在時刻、指定されればその日時を使う。
STEP 5 / 6

ネストした状態の更新: _withUpdatedSets

ActiveWorkoutState は3層のネスト構造を持つ:

ActiveWorkoutState
  ├── routineName: String
  ├── startedAt: DateTime
  └── exercises: List<ActiveExerciseData>
        ├── exerciseId: String
        ├── name: String
        └── sets: List<SetData>
              ├── weight: double
              ├── reps: int
              └── isPR: bool

「2番目の種目の1番目のセットの重量」を変えるには、3層すべてを新しく作り直す必要がある:

ActiveWorkoutState _withUpdatedSets(
  int exerciseIndex, List<SetData> newSets,
) {
  final currentState = state!;
  return currentState.copyWith(
    exercises: [
      for (var i = 0; i < currentState.exercises.length; i++)
        if (i == exerciseIndex)
          currentState.exercises[i].copyWith(sets: newSets)
        else
          currentState.exercises[i],
    ],
  );
}
「exerciseIndex に一致する種目だけ sets を差し替え、それ以外はそのまま。全体を新しい ActiveWorkoutState として返す」
トレーニングカードの「脚の日」の3セット目だけ書き換えたいとき、紙全体をコピーして該当箇所だけ修正する。他のページ(種目)は元のまま。
STEP 6 / 6

keepAlive とセッションのライフサイクル

ActiveWorkoutNotifierkeepAlive: true で定義されている。セッションのライフサイクル全体:

1. アプリ起動
   └── build() → null (セッション未開始)

2. セッション開始
   └── startSession() → state = ActiveWorkoutState(...)

3. データ入力(画面遷移しても state 保持)
   ├── updateWeight() → state = state.copyWith(...)
   ├── updateReps() → state = state.copyWith(...)
   └── addSet() → state = state.copyWith(...)

4. セッション完了
   └── completeSession() → Repository に保存 → state = null

5. セッション破棄
   └── discardSession() → state = null

state = null で「セッション未開始」に戻る。keepAlive でもメモリリークしないのは、null に戻すことで実質データを解放しているため。

Q1. ActiveWorkoutNotifierbuild()null を返す理由は?
null は「セッションが始まっていない」ことを意味する。startSession() で値が入り、completeSession()/discardSession() で null に戻る。nullable 型でセッションの有無を表現している。
Q2. startSession(data, targetDate: null) を呼ぶと startedAt はどうなる?
targetDate ?? DateTime.now()?? 演算子により、targetDate が null のとき DateTime.now() がフォールバック値として使われる。これが「パラメータ→状態フィールドの変換」パターン。
Q3. 「2番目の種目の重量を変更」するとき、なぜ全 exercises リストを新しく作るのか?
freezed で生成されたクラスは全フィールドが不変。state = 新しいオブジェクト を代入することで Riverpod が変更を検知し、watch している Widget が再描画される。直接変更ではこの検知が働かない。

3-3 まとめ

  • Notifier = 「状態 + 操作メソッド」のセット。build() で初期状態、メソッドで state を更新
  • パラメータ→状態変換パターン: targetDate ?? DateTime.now().map でデフォルト値生成
  • ネスト更新は copyWith を連鎖させる。freezed がイミュータブルを保証し、Riverpod が変更を検知
TOPIC 3-4

AsyncValue と .family

非同期データの3状態を安全に扱い、パラメータ付きProviderで柔軟にデータ取得する

0%
STEP 1 / 5

AsyncValue の3状態: loading / error / data

DB からデータを読むのは非同期処理。「読み込み中」「エラー」「成功」の3つの状態がある。

// FutureProvider は自動的に AsyncValue を返す
final routineListProvider = FutureProvider<List<RoutineWithExercises>>((ref) {
  final repo = ref.watch(routineRepositoryProvider);
  return repo.getAllRoutinesWithExercises();
});
lib/features/routine/routine_provider.dart

この Provider を ref.watch すると、AsyncValue<List<RoutineWithExercises>> が返る。

状態意味UIの表示例
AsyncLoadingデータ読み込み中くるくるインジケータ
AsyncError読み込みエラーエラーメッセージ
AsyncDataデータ取得成功ルーティンカード一覧
ジムの予約アプリと同じ。開くと「読み込み中...」→ 成功すれば「空きコース一覧」が表示、通信エラーなら「接続できません」が表示される。
STEP 2 / 5

.when() で3状態をハンドリング

lib/features/home/home_screen.dart
Widget build(BuildContext context, WidgetRef ref) {
  final routineListAsync = ref.watch(routineListProvider);
  return Scaffold(
    body: ...
      routineListAsync.when(
        loading: () =>
            const Center(child: CircularProgressIndicator()),
        error: (error, _) => Center(
          child: Text('エラー: $error'),
        ),
        data: (routineList) => ListView.separated(
          itemCount: routineList.length + 1,
          itemBuilder: (context, index) { ... },
        ),
      ),
  );
}
「routineListAsync の状態に応じて、loading ならインジケータ、error ならメッセージ、data ならリスト表示を返す」
なぜ .when() を使う? 3状態すべてのハンドリングを 強制 してくれるから。if-else で書くと loading の処理を忘れるリスクがある。.when() なら書き忘れるとコンパイルエラーになる。
.when()if-else
網羅性3状態すべて必須忘れてもコンパイル通る
可読性状態ごとに明確ネストが深くなる
STEP 3 / 5

.family: パラメータ付き Provider

「月ごとのトレーニング日」を取得する Provider を見てみよう:

lib/features/home/home_provider.dart lib/features/home/home_provider.dart
final sessionDatesForMonthProvider =
    FutureProvider.family<List<DateTime>, ({int year, int month})>((
      ref,
      params,
    ) {
      ref.watch(allWorkoutsProvider);
      return ref
          .watch(historyRepositoryProvider)
          .getSessionDatesForMonth(params.year, params.month);
    });
「sessionDatesForMonthProvider は year と month をパラメータとして受け取り、その月のトレーニング日リストを返す」

使う側:

// 2026年3月のトレーニング日を取得
final dates = ref.watch(
  sessionDatesForMonthProvider((year: 2026, month: 3))
);
// 2026年4月のトレーニング日を取得(別のキャッシュ)
final nextDates = ref.watch(
  sessionDatesForMonthProvider((year: 2026, month: 4))
);

.family の判断基準:

Provider に渡すパラメータが必要?
  |
  +-- NO  --> 通常の Provider で十分
  |           例: routineListProvider(パラメータなし)
  |
  +-- YES --> .family を使う
              |
              同じパラメータで複数回呼ばれる?
              +-- YES --> キャッシュが効いて効率的
              |           例: sessionDatesForMonthProvider(同じ月は再取得不要)
              +-- NO  --> それでも .family は有効
                          例: lastSessionDateProvider(routineId)
STEP 4 / 5

.family のバリエーション

筋トレメモには3種類の .family Provider がある:

lib/features/home/home_provider.dart
// 1. Record型パラメータ(複数値をまとめる)
FutureProvider.family<List<DateTime>, ({int year, int month})>

// 2. 単一パラメータ(DateTime)
FutureProvider.family<List<Workout>, DateTime>

// 3. 単一パラメータ(String)
FutureProvider.family<DateTime?, String>
lib/features/history/history_provider.dart
// 4. 詳細取得(sessionId で引く)
FutureProvider.family<WorkoutDetail, String>
Providerパラメータ用途
sessionDatesForMonthProvider({int year, int month})月ごとのカレンダードット
sessionsForDateProviderDateTime日付タップ時の詳細
lastSessionDateProviderString (routineId)ルーティンカードの「前回」表示
workoutDetailProviderString (sessionId)履歴詳細画面
STEP 5 / 5

コード生成の限界: 手動定義が必要なケース

筋トレメモでは、一部の Provider を @riverpod ではなく手動で定義している:

lib/features/routine/routine_provider.dart
/// drift 生成型を riverpod_generator で扱えないため手動で定義
final routineListProvider = FutureProvider<List<RoutineWithExercises>>((ref) {
  final repo = ref.watch(routineRepositoryProvider);
  return repo.getAllRoutinesWithExercises();
});

コメントに「drift 生成型を riverpod_generator で扱えないため手動で定義」と書かれている。

@riverpod で書けない場合:

  • drift の生成型: RoutineWithExercises は drift が生成した型。riverpod_generator がこの型を解析できない
  • DateTime 型のパラメータ: .family のパラメータに DateTime を使う場合、コード生成では型の等価性判定がうまく動かないことがある
  • Record 型のパラメータ: ({int year, int month}) のような Record 型もコード生成が非対応
@riverpod(コード生成)手動定義
使い時標準的な型(String, int等)のProviderdrift生成型、DateTime、Record型パラメータ
workoutRepository, historyRepositoryroutineListProvider, sessionDatesForMonthProvider
メリットボイラープレート削減どんな型でも使える
デメリット一部の型が非対応定義がやや冗長
なぜコード生成が万能でないのか? riverpod_generator は Dart のソースコードを静的解析して Provider を生成する。drift が生成する型は別のコード生成器の出力なので、riverpod_generator が解析するタイミングでは「まだ存在しない型」になり、エラーが起きる。
Q1. .when() の loading / error / data のうち、1つでも書き忘れるとどうなる?
.when() は3つの引数すべてが必須。1つでも抜けるとコンパイルエラー。これが「状態の網羅漏れ」を防ぐ仕組み。
Q2. sessionDatesForMonthProvider((year: 2026, month: 3))sessionDatesForMonthProvider((year: 2026, month: 4)) は同じキャッシュを使う?
.family はパラメータごとに独立したインスタンスを作る。同じ (year: 2026, month: 3) で呼べばキャッシュが効くが、別のパラメータなら別のインスタンスになる。
Q3. routineListProvider を @riverpod ではなく手動で定義している理由は?
riverpod_generator は静的解析時に drift の生成型が見つからずエラーになる。2つのコード生成器の出力が互いに依存する場合、手動定義が必要になる。

3-4 まとめ

  • AsyncValue の3状態 (loading / error / data) を .when() で網羅的にハンドリングする
  • .family はパラメータ付き Provider。同じパラメータならキャッシュが効く
  • コード生成(@riverpod)は万能ではない。drift 生成型や DateTime パラメータには手動定義が必要
TOPIC 3-5

スコープと Override

テストでの Provider 差し替え、N+1問題、レイヤー設計の価値を理解する

0%
STEP 1 / 6

ProviderContainer: Provider の入れ物

通常のアプリでは ProviderScope が Widget ツリーのトップにある。テストでは ProviderContainer を直接作る:

test/features/workout/workout_provider_test.dart
container = ProviderContainer(
  overrides: [
    appDatabaseProvider.overrideWithValue(db),
    currentUserIdProvider.overrideWithValue(null),
  ],
);
「テスト用の ProviderContainer を作る。appDatabaseProvider をインメモリDB(db)で差し替え、currentUserIdProvider を null で差し替える」
筋トレのトレーニングメニューを「本番用」と「体験版」で切り替えるようなもの。Container がメニュー表で、override が「この種目をこの代替種目に差し替え」の指示。
STEP 2 / 6

override: テスト用にProviderを差し替える

テストで本番DBではなくインメモリDBを使う仕組み:

test/features/workout/workout_provider_test.dart
late AppDatabase db;
late ProviderContainer container;

setUp(() async {
  db = AppDatabase.forTesting(NativeDatabase.memory());
  container = ProviderContainer(
    overrides: [
      appDatabaseProvider.overrideWithValue(db),
      currentUserIdProvider.overrideWithValue(null),
    ],
  );
});

何が起きているか:

  1. AppDatabase.forTesting(NativeDatabase.memory()) でインメモリDB(テスト後に消える)を作成
  2. appDatabaseProvider.overrideWithValue(db) で、本番DBの代わりにインメモリDBを注入
  3. WorkoutRepository も内部で ref.watch(appDatabaseProvider) しているので、自動的にインメモリDBを使う
override ありoverride なし
DBテスト専用のインメモリDB本番DBに接続
テスト後DBが自動消去本番データが汚染
テスト速度高速(メモリ上)低速(ファイルI/O)
並列実行各テストが独立テスト同士が干渉
STEP 3 / 6

setUp / tearDown: テストのライフサイクル

test/features/workout/workout_provider_test.dart
setUp(() async {
  db = AppDatabase.forTesting(NativeDatabase.memory());
  container = ProviderContainer(
    overrides: [
      appDatabaseProvider.overrideWithValue(db),
      currentUserIdProvider.overrideWithValue(null),
    ],
  );
  addTearDown(() async {
    container.dispose();
    await db.close();
  });
  notifier = container.read(activeWorkoutProvider.notifier);
});

テスト1回ごとのライフサイクル:

setUp
  ├── 1. インメモリDB作成
  ├── 2. Container作成(DBをoverride)
  └── 3. Notifier取得

テスト本体
  ├── notifier.startSession(...)
  ├── notifier.updateWeight(...)
  └── expect(...)

tearDown
  ├── 1. Container破棄(全Provider解放)
  └── 2. DB接続を閉じる
なぜ毎回作り直す? テスト間でデータが残ると、テストの順番で結果が変わる「不安定テスト」になる。毎回クリーンな状態から始めることで、どのテストも独立して再現可能になる。
STEP 4 / 6

Repository = テスト容易性の鍵

テストが書きやすいのは、Repository パターンのおかげ。以下はDB操作のテスト:

test/features/workout/workout_repository_test.dart
setUp(() async {
  db = AppDatabase.forTesting(NativeDatabase.memory());
  repository = WorkoutRepository(db);
});

test('前回記録がない場合は空リストを返す', () async {
  final result = await repository.getLastSessionSets(exerciseId);
  expect(result, isEmpty);
});

Repository テストのポイント:

  • Widget(UI)なしでロジックだけテストできる
  • DB をインメモリに差し替えているので高速
  • 「保存→取得→検証」のパターンで確実に動作を保証
// テストの典型パターン: 保存 → 取得 → 検証
await saveTestSession(       // 1. 保存
  exerciseId: exerciseId,
  exerciseName: 'ベンチプレス',
  sets: [SetData(weight: 60, reps: 10)],
);
final result = await repository.getLastSessionSets(exerciseId); // 2. 取得
expect(result.length, 1);    // 3. 検証
expect(result[0].weight, 60.0);
もし Repository がなかったら? UI コンポーネントを起動し、ボタンをタップし、表示を確認する「Widget テスト」が必要になる。Repository があれば、UI なしでビジネスロジックだけを高速にテストできる。
STEP 5 / 6

N+1 問題: getSessionDetail の落とし穴

筋トレメモの getSessionDetail にはパフォーマンスの改善余地がある:

lib/features/history/history_repository.dart
Future<WorkoutDetail> getSessionDetail(String workoutId) async {
  // 1回目のクエリ: Workout を取得
  final workout = await (...).getSingle();

  // 2回目のクエリ: WorkoutExercises を取得
  final workoutExercises = await (...).get();

  // N回のクエリ: 各 Exercise の Sets を取得
  for (final ex in workoutExercises) {
    final sets = await (...).get();  // ここが N 回実行される!
    exercisesWithSets.add(...);
  }
}

N+1 問題とは:

  • 1回のクエリで「種目リスト」を取得(1回目)
  • 各種目ごとにループで「セット記録」を取得(N回)
  • 合計 1 + N 回 のクエリが実行される

種目が3つなら 4回のクエリ。10種目なら 11回。種目数が増えるほど遅くなる。

筋トレの記録を見返すとき、まず「今日の種目リスト」を開き(1回)、次にベンチプレスのセット記録を別のページで開き(2回目)、スクワットのセット記録を開き(3回目)...。全ページを1回で開ければ速いのに、種目ごとにページめくりが必要になっている状態。

改善案: JOIN で一括取得

// 改善版: 1回のクエリで全データを JOIN で取得
final rows = await (select(workoutExercises)
  .join([
    innerJoin(workoutSets,
      workoutSets.workoutExerciseId.equalsExp(workoutExercises.id)),
  ])
  ..where(workoutExercises.workoutId.equals(workoutId))
).get();
// → 1回のクエリで全データ取得。あとは Dart 側でグループ化
ローカルDBのSQLiteではN+1の影響は小さい(ネットワーク遅延がないため)。しかし Firebase Data Connect (PostgreSQL) に移行すると、各クエリがネットワーク経由になるため深刻なパフォーマンス問題になる。今のうちに意識しておくことが大切。
STEP 6 / 6

レイヤー設計のまとめ: 全体像

筋トレメモの全レイヤー構造を振り返ろう:

UI 層 (Flutter Widget)
  HomeScreen, WorkoutScreen, HistoryScreen
  ├── ref.watch で Provider を監視
  └── ref.read で Notifier のメソッドを呼ぶ
        |
Provider 層 (Riverpod)
  activeWorkoutProvider, routineListProvider, allWorkoutsProvider
  ├── Notifier: 状態管理 + ビジネスロジック
  └── FutureProvider / StreamProvider: 非同期データ供給
        |
Repository 層
  WorkoutRepository, HistoryRepository, RoutineRepository
  ├── ドメイン ↔ DB 形式の変換
  └── DB操作のカプセル化
        |
DB 層 (drift / SQLite)
  AppDatabase, テーブル定義, マイグレーション

各レイヤーの役割と、override によるテスト戦略:

レイヤー責務テスト方法
UI表示 + ユーザー操作Widget テスト(今後)
Provider/Notifier状態管理 + ロジックProviderContainer + DB override
Repositoryドメイン↔DB変換インメモリDB直接注入
DBデータ永続化マイグレーションテスト
なぜレイヤーを分ける? 「変更の影響範囲を限定する」ため。例えば SQLite から PostgreSQL に移行するとき、Repository 層の内部だけ変更すればよく、UI もNotifier も変更不要。これが「ローカルファースト設計」の真価。
Q1. テストで appDatabaseProvider.overrideWithValue(db) を使う目的は?
override により、本番のファイルベースDBではなくインメモリDBが使われる。テスト後に自動消去され、各テストが独立して動作する。これが Repository パターン + Provider の大きなメリット。
Q2. N+1問題の「N」は何を指す?
「1回で種目リストを取得」+「N回(種目数分)セット記録を個別取得」= 1+N回のクエリ。JOIN を使えば1回で済む。ローカルDBでは影響小だが、リモートDBでは深刻な遅延になる。
Q3. SQLite から PostgreSQL に移行するとき、レイヤー設計のおかげでどこだけ変更すれば済む?
Repository が「ドメイン↔DB形式の変換」をカプセル化しているため、DB 層の変更は Repository の内部で吸収される。UI も Notifier も「Repository のメソッドを呼ぶ」だけなので変更不要。

3-5 まとめ

  • ProviderContainer + override でテスト時にDBを差し替え、独立・高速なテストを実現
  • N+1問題: forループ内のクエリは種目数に比例して遅くなる。JOIN で一括取得が改善策
  • レイヤー設計の価値: 変更影響の局所化、テスト容易性、将来のリモートDB移行への備え

Lv.3 全体まとめ

  • 3-1: Provider = 管理された共有。setState との判断基準は「複数Widgetで共有するか」
  • 3-2: watch(build内) / read(コールバック) / listen(副作用)。中間層を飛ばすとロジック散逸
  • 3-3: Notifier = 状態+操作。パラメータ→状態変換、copyWith でネスト更新
  • 3-4: AsyncValue の .when() で3状態網羅。.family でパラメータ付き。コード生成の限界
  • 3-5: override でテスト容易に。N+1問題。レイヤー設計で変更影響を局所化