😌

【Flutter】Riverpod Mutationsを使ってUIの状態管理をする

に公開
2025/12/09

株式会社GENDA モバイル開発部の名取です。
この記事は GENDA Advent Calendar 2025 シリーズ3 Day 8 の記事です。

https://qiita.com/advent-calendar/2025/genda

この記事の概要

  • Riverpod 3.0から導入されたMutationsについての解説記事です
  • Mutationsとは「UI側にはできるだけ同期的にstateを扱わせつつ、裏側では非同期処理を安全に走らせる」ための専用 APIです
  • Mutationsを利用することで従来の AsyncNotifierやFutureProviderだけでは扱いづらかった 非同期の「副作用付きの状態変更」 をより書きやすくなります

Mutationsに関する公式ドキュメントは下記をご参照ください!
https://riverpod.dev/docs/concepts2/mutations

導入

私はFlutterアプリの開発において、Riverpodを好んで使用します。

Riverpodはテスト実装のしやすや再利用可能であること、UIからロジックを分離しやすいなどの理由から導入しているプロジェクトは多いと思います。

素晴らしい状態管理ライブラリですが、UIに関して読み込み状態、エラー状態、成功状態を処理するための大量の定型コードが必要になってしまったり、複数の操作があるときにその操作状態が同一のAsyncValueに混在して実装がごちゃついてしまう課題を感じることがしばしばあります。

色々調べる中でRIverpod 3.0から導入されたMutationsという機能がこれを解決しそうで興味深かったためどんなものかサンプルコードとともに紹介します。

なお、Mutationsは実験的な機能です。実プロダクトでの導入には十分注意を払う必要があります。

Mutations are experimental, and the API may change in a breaking way without a major version bump

Mutations サンプルコード

よくありがちなログインボタンを押下 → ログインAPIリクエストを例にMutationsについてみていきましょう!

Mutationsを使わずにログインの実装を行う

ログインボタンを押下してログインAPIをリクエストする場合、下記のような実装をすることがあると思います。

// provider
@riverpod
class AuthNotifier extends _$AuthNotifier {
  @override
  AsyncValue<User?> build() => const AsyncData(null);

  Future<void> login(String email, String password) async {
    state = const AsyncLoading();
    try {
      final user = await ref
          .read(authRepositoryProvider)
          .login(email, password);
      state = AsyncData(user);
    } catch (e, st) {
      state = AsyncError(e, st);
    }
  }
}

// UI
class LoginScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final authState = ref.watch(authNotifierProvider);

    return ElevatedButton(
      onPressed: authState.isLoading
          ? null
          : () =>
                ref.read(authNotifierProvider.notifier).login(email, password),
      child: authState.isLoading
          ? const CircularProgressIndicator()
          : const Text('ログイン'),
    );
  }
}

この実装の問題点としては下記の3つが挙げられると思います。

  1. 状態の混在: User?のデータ状態と「ログイン中かどうか」の操作状態が同じAsyncValueに混在
  2. 複数操作の管理が困難: ログイン、ログアウト、プロフィール更新など複数の操作があると、どの操作が実行中かわからない
  3. 成功/エラー後の状態管理: 成功後にローディング表示を消す、エラー後にリトライ可能にするなどの管理が煩雑

実際はログイン、ログアウトなど操作ごとに別々のProviderを作ることで各操作の状態を適切に管理することは可能ですが、下記のような実装となりボイラープレートが多くなってしまいがちです。

// ログイン用
@riverpod
class LoginNotifier extends _$LoginNotifier {
  @override
  AsyncValue<void> build() => const AsyncData(null);

  Future<void> login(String email, String password) async {
    state = const AsyncLoading();
    try {
      await ref.read(authRepositoryProvider).login(email, password);
      state = const AsyncData(null);
    } catch (e, st) {
      state = AsyncError(e, st);
    }
  }
}

// ログアウト用
@riverpod
class LogoutNotifier extends _$LogoutNotifier {
  @override
  AsyncValue<void> build() => const AsyncData(null);

  Future<void> logout() async {
    state = const AsyncLoading();
    try {
      await ref.read(authRepositoryProvider).logout();
      state = const AsyncData(null);
    } catch (e, st) {
      state = AsyncError(e, st);
    }
  }
}

// ユーザーデータ用(別で管理)
@riverpod
class CurrentUser extends _$CurrentUser {
  @override
  User? build() => null;

  void set(User? user) => state = user;
}

Mutationsを使ってログインの実装を行う

Mutationsを使うと下記のように実装が可能となります。

// provider
@riverpod
class AuthNotifier extends _$AuthNotifier {
  // データ状態
  @override
  User? build() => null;

  // 操作状態(Mutation)
  final login = Mutation<User>();
  final logout = Mutation<void>();

  Future<void> performLogin(String email, String password) async {
    await login.run((tsx) async {
      final user = await tsx
          .read(authRepositoryProvider)
          .login(email, password);
      state = user;
      return user;
    });
  }

  Future<void> performLogout() async {
    await logout.run((tsx) async {
      await tsx.read(authRepositoryProvider).logout();
      state = null;
    });
  }
}

// UI
class LoginScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final notifier = ref.watch(authNotifierProvider.notifier);
    final loginState = ref.watch(notifier.login);

    return ElevatedButton(
      onPressed: loginState is MutationPending
          ? null
          : () => notifier.performLogin(email, password),
      child: switch (loginState) {
        MutationPending() => const CircularProgressIndicator(),
        MutationError(:final error) => Text('エラー: $error'),
        MutationSuccess() => const Text('成功!'),
        _ => const Text('ログイン'),
      },
    );
  }
}

先ほどの実装と比較した時のメリットは下記です。

  1. データ状態と操作状態が明確に分離できる
  2. 各操作に独立したMutationを持てる(Providerをたくさん作る必要がなくなる)
  3. MutationSuccessという成功状態を明示できる

どうでしょうか?個人的には非常にロジックの把握が容易となり実装ミスを減らすことができると感じました!

まとめ

Mutationsは必須ではありませんが、以下の場合に特に有用です:

  1. 複数の非同期操作を持つNotifierがある
  2. 成功/エラー状態をUIで明示的に表示したい
  3. データ状態と操作状態を分離してコードを整理したい

ただし、RiverpodのMutationsはまだ実験的機能なのでプロダクションでの利用は避けた方が無難だと思います。

最後まで読んでいただきありがとうございました!

Discussion