Flutter

Riverpod v3 移行で実際につまずいたこと全部書く

既存の Flutter アプリを Riverpod v2 から v3 へ移行しました。

「StateNotifier を Notifier に書き換えるだけ」と思っていたのですが、実際には予想外の問題がいくつか出てきました。この記事では実際につまずいた点を中心に書きます。

移行の全体像

移行は 4 フェーズで進めました。

Phase 1: テスト整備(先にやらないと詰む)

Phase 2: 不要パッケージ削除 + 環境変数整理

Phase 3: パッケージバージョンアップ

Phase 4: StateNotifier → Notifier/@riverpod 書き換え

つまずき1: テストなしでの移行は危険

移行前の状態はテストがほぼゼロでした。これが最初の問題でした。

StateNotifier を書き換えた後、「動いてるかどうかわからない」状態になります。 ViewModel の返す状態が変わってもコンパイルエラーにはならないため、実行してみるまでわかりません。

先にテストを整備する

// test/helpers/test_helpers.dart
ProviderContainer createContainer({
  List<Override> overrides = const [],
}) {
  final container = ProviderContainer(overrides: overrides);
  addTearDown(container.dispose);
  return container;
}
// recipes_view_model_test.dart
test('レシピ一覧を取得できる', () async {
  final container = createContainer(
    overrides: [
      recipeRepositoryProvider.overrideWith(
        (ref) => MockRecipeRepository(),
      ),
    ],
  );

  // 状態変化を listener で検証
  final listener = Listener<AsyncValue<RecipesState>>();
  container.listen(recipesViewModelProvider, listener, fireImmediately: true);

  await container.read(recipesViewModelProvider.future);
  verify(() => listener(any(), any())).called(greaterThan(0));
});

ViewModelのテストを先に書いておくことで、書き換え後に同じテストが通るかを確認できます。


つまずき2: AsyncValue.value の挙動変化

v2 で最もはまったのがこれです。

v2 では AsyncValue.value はエラー時に null を返していました。v3 では同じプロパティが存在しますが、エラー状態で .value にアクセスすると例外をスローするように変わっています。

// v2: エラー時は null が返る(サイレントに失敗する)
final value = ref.watch(someProvider).value; // エラー時 null

// v3: エラー時は例外 → クラッシュする
final value = ref.watch(someProvider).value; // ❌ エラー時に throw

// ✅ v3での正しい書き方
ref.watch(someProvider).when(
  data: (value) => ...,
  loading: () => ...,
  error: (e, st) => ...,
);

// または
final value = ref.watch(someProvider).valueOrNull; // エラー時は null

既存コードに .value を直接使っている箇所が複数あり、一つひとつ確認して修正しました。


つまずき3: StateNotifier → Notifier の書き換えパターン

シンプルな StateNotifier の場合

// Before
final selectedRecipeProvider = StateNotifierProvider<
  SelectedRecipeNotifier, Recipe?>(
  (ref) => SelectedRecipeNotifier(),
);

class SelectedRecipeNotifier extends StateNotifier<Recipe?> {
  SelectedRecipeNotifier() : super(null);

  void select(Recipe recipe) => state = recipe;
  void clear() => state = null;
}
// After
@riverpod
class SelectedRecipeState extends _$SelectedRecipeState {
  @override
  Recipe? build() => null;

  void select(Recipe recipe) => state = recipe;
  void clear() => state = null;
}

非同期処理がある ViewModel の場合

// Before: StateNotifier + 手動での AsyncValue 管理
class RecipesViewModel extends StateNotifier<RecipesState> {
  RecipesViewModel(this._repository) : super(RecipesState.loading()) {
    _load();
  }

  Future<void> _load() async {
    try {
      final recipes = await _repository.fetchAll();
      state = RecipesState.loaded(recipes);
    } catch (e) {
      state = RecipesState.error(e.toString());
    }
  }
}
// After: AsyncNotifier で AsyncValue を自動管理
@riverpod
class RecipesViewModel extends _$RecipesViewModel {
  @override
  Future<RecipesState> build() async {
    final repository = ref.watch(recipeRepositoryProvider);
    final recipes = await repository.fetchAll();
    return RecipesState(recipes: recipes);
  }

  Future<void> refresh() async {
    state = const AsyncLoading();
    state = await AsyncValue.guard(() async {
      final repository = ref.read(recipeRepositoryProvider);
      final recipes = await repository.fetchAll();
      return RecipesState(recipes: recipes);
    });
  }
}

v3 の AsyncNotifier では build() が初回ロードを担当します。ローディング・エラー状態は AsyncValue が自動で管理するため、カスタムの状態クラスが不要になることが多いです。


つまずき4: コード生成ステップが増える

v3 の @riverpod アノテーションはコード生成を前提としています。

# pubspec.yaml
dev_dependencies:
  riverpod_generator: ^2.x.x
  build_runner: ^2.x.x

書き換え後は毎回 build_runner を実行する必要があります。

dart run build_runner build --delete-conflicting-outputs

_$RecipesViewModel のようなプライベートクラスが生成されますが、これを知らないと「クラスが見つからない」エラーで詰まります。

VS Code の設定

毎回コマンドを叩くのが面倒なため、tasks.json に登録しました。

{
  "label": "build_runner watch",
  "type": "shell",
  "command": "dart run build_runner watch --delete-conflicting-outputs",
  "isBackground": true,
  "group": "build"
}

watch にしておくとファイル保存のたびに自動で再生成されます。


つまずき5: 複数パッケージのバージョン調整

Riverpod 関連パッケージが複数あり、バージョンを合わせないとコンパイルエラーになります。

パッケージv2 → v3
riverpod2.6.1 → 3.2.1
flutter_riverpod2.6.1 → 3.3.1
hooks_riverpod2.6.1 → 3.3.1
riverpod_annotation2.x → 3.x
riverpod_generator2.x → 3.x

flutter_riverpodhooks_riverpod のバージョンが riverpod と一致していないとエラーになります。

Because flutter_riverpod >=3.3.1 requires riverpod ^3.2.1, version solving failed.

pubspec.yaml で全パッケージのバージョンを確認してから一括で上げるのが安全です。


つまずき6: flutter_dotenv を捨てる機会にした

v2 → v3 の移行中、flutter_dotenv を使って .env ファイルから API キーを読み込んでいたのをやめました。

理由:

  • flutter_dotenv.env ファイルをアセットとしてバンドルするため、apk/ipa を解凍すれば中身が見える
  • --dart-define-from-file を使えばビルド時にコンパイルされ、バイナリに埋め込まれる
# Before: .env をアセットとして同梱(危険)
flutter run --flavor dev

# After: dart-define-from-file でビルド時注入
flutter run --flavor dev --dart-define-from-file=config/dev.json
// lib/utils/env/app_env.dart
class AppEnv {
  static const apiKey = String.fromEnvironment('API_KEY');
  static const apiBaseUrl = String.fromEnvironment('API_BASE_URL');
}

v3 移行と同時にやると変更箇所が増えて大変でしたが、セキュリティ上重要な対応だったのでここで一緒にやって良かったです。


まとめ

つまずき解決策
テストなしで移行すると検証できない先にテストを整備する
AsyncValue.value でクラッシュwhen / valueOrNull に置き換える
StateNotifier 書き換えパターンがわからないNotifier / AsyncNotifier を使い分ける
コード生成を知らなくてエラーbuild_runner watch をタスクに登録
パッケージバージョンが合わない全 Riverpod 関連パッケージを同時に上げる

Riverpod v3 は v2 よりエラーに対して厳格になっています。v2 のコードが「なんとなく動いていた」場合、v3 では明示的なエラーになります。特に AsyncValue.value のアクセス方法の変化は影響範囲が広いため、先に検索して全部洗い出してから移行するのをおすすめします。