Zenn
🏄‍♂️

Riverpod & Riverpod Generatorを利用して状態管理部分の処理を書き換えてみる簡単な事例紹介

2025/03/16に公開
3
1

1. はじめに

Flutterの状態管理には多くの選択肢がありますが、その中でも特に注目されているのがRiverpodです。従来のProviderパターンの問題点を解決し、型安全で保守性の高いコードを実現できるこのライブラリは、多くの開発者から支持を集めています。

特に注目すべき点として、これまで良く利用されていたStateNotifierやStateProviderは、Riverpod2.x系からは非推奨となっており、書き換える必要があります。Riverpodの最新バージョンでは、より直感的かつシンプルなAsyncNotifierやNotifierの使用が推奨されています。

さらに、Riverpod Generatorを併用することで、ボイラープレートコードを大幅に削減できるようになりました。

本記事では、既存の状態管理コードをRiverpodとRiverpod Generatorを使って書き換える実践的な事例を紹介します。シンプルな例から始めて、段階的に理解を深めていくことで、Riverpodの基本的な概念と実装方法を知れると思います。コード量の削減、保守性の向上、そして開発効率の改善がどのように実現されるのかを、具体的なコード例とともに解説していきます。

【参考記事】

https://riverpod.dev/ja/docs/migration/from_state_notifier
https://zenn.dev/koichi_51/articles/e98d13089d5ad3
https://zenn.dev/shintykt/articles/020858a88d1536

2. 新しいRiverpodの書き方を利用して郵便番号APIからデータを取得するだけのサンプル

https://github.com/fumiyasac/new_riverpod_postalcode_example

郵便番号APIを利用して、検索した郵便番号(例: 100-0001)から下記の情報を取得して表示するサンプルになります。

  • 都道府県コード番号
  • 都道府県名
  • 市区町村名

郵便番号API

新しいRiverpodを利用して構築する

Riverpod2.0からはStateNotifierが非推奨となり、@riverpodを利用した書き方に変更しています。

想定アーキテクチャ概要

新旧の書き方を比較する

① Address.daft (Model)

【Before】

address.dart
class Address {
  final String prefcode;
  final String prefecture;
  final String address1;
  final String address2;

  Address({
    required this.prefcode,
    required this.prefecture,
    required this.address1,
    required this.address2,
  });

  factory Address.fromJson(Map<String, dynamic> json) {
    return Address(
      prefcode: json['data'][0]['prefcode'] ?? '',
      prefecture: json['data'][0]['ja']['prefecture'] ?? '',
      address1: json['data'][0]['ja']['address1'] ?? '',
      address2: json['data'][0]['ja']['address2'] ?? '',
    );
  }
}

【After】

address.dart
import 'package:freezed_annotation/freezed_annotation.dart';

part 'address.freezed.dart';
part 'address.g.dart';


class Address with _$Address {

  const factory Address({
    required String prefcode,
    required String prefecture,
    required String address1,
    required String address2,
  }) = _Address;

  factory Address.fromJson(Map<String, dynamic> json) =>
    _$AddressFromJson({
      'prefcode': json['data'][0]['prefcode'] ?? '',
      'prefecture': json['data'][0]['ja']['prefecture'] ?? '',
      'address1': json['data'][0]['ja']['address1'] ?? '',
      'address2': json['data'][0]['ja']['address2'] ?? '',
    });
}

※ 追加後に$ flutter pub run build_runner buildを実行する。

② PostalCodeRepository.daft (Repository)

【Before】

postal_code_repository.dart
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../models/address.dart';

class PostalCodeRepository {
  Future<Address> getAddress(String postalCode) async {
    final cleanPostalCode = postalCode.replaceAll('-', '/');
    final response = await http.get(
      Uri.parse('https://madefor.github.io/postal-code-api/api/v1/$cleanPostalCode.json'),
    );

    if (response.statusCode == 200) {
      return Address.fromJson(json.decode(response.body));
    } else {
      throw Exception('住所の取得に失敗しました');
    }
  }
}

【After】

postal_code_repository.dart
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:http/http.dart' as http;
import 'dart:convert';
import '../models/address.dart';

part 'postal_code_repository.g.dart';


PostalCodeRepository postalCodeRepository(Ref ref) {
  return PostalCodeRepository();
}

class PostalCodeRepository {
  Future<Address> getAddress(String postalCode) async {
    final cleanPostalCode = postalCode.replaceAll('-', '/');
    final response = await http.get(
      Uri.parse('https://madefor.github.io/postal-code-api/api/v1/$cleanPostalCode.json'),
    );

    if (response.statusCode == 200) {
      return Address.fromJson(json.decode(response.body));
    } else {
      throw Exception('住所の取得に失敗しました');
    }
  }
}

※ 追加後に$ flutter pub run build_runner buildを実行する。

③ postal_code_state.daft (State)

【Before】

postal_code_repository.dart
import '../models/address.dart';

class PostalCodeState {
  final bool isLoading;
  final String? errorMessage;
  final Address? address;
  final String postalCode;

  PostalCodeState({
    this.isLoading = false,
    this.errorMessage,
    this.address,
    this.postalCode = '',
  });

  PostalCodeState copyWith({
    bool? isLoading,
    String? errorMessage,
    Address? address,
    String? postalCode,
  }) {
    return PostalCodeState(
      isLoading: isLoading ?? this.isLoading,
      errorMessage: errorMessage,
      address: address ?? this.address,
      postalCode: postalCode ?? this.postalCode,
    );
  }
}

【After】

postal_code_repository.dart
import 'package:freezed_annotation/freezed_annotation.dart';
import '../models/address.dart';

part 'postal_code_state.freezed.dart';


class PostalCodeState with _$PostalCodeState {
  const factory PostalCodeState({
    (false) bool isLoading,
    String? errorMessage,
    Address? address,
    ('') String postalCode,
  }) = _PostalCodeState;
}

※ 追加後に$ flutter pub run build_runner buildを実行する。

④ postal_code_provider.daft (Provider)

【Before】

postal_code_repository.dart
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_example/view_models/postal_code_state.dart';
import '../repositories/postal_code_repository.dart';

final postalCodeRepositoryProvider = Provider((ref) => PostalCodeRepository());

final postalCodeViewModelProvider = StateNotifierProvider<PostalCodeViewModel, PostalCodeState>(
      (ref) => PostalCodeViewModel(ref.read(postalCodeRepositoryProvider)),
);

class PostalCodeViewModel extends StateNotifier<PostalCodeState> {
  final PostalCodeRepository _repository;

  PostalCodeViewModel(this._repository) : super(PostalCodeState());

  void updatePostalCode(String postalCode) {
    state = state.copyWith(postalCode: postalCode);
  }

  Future<void> searchAddress() async {
    if (state.postalCode.isEmpty) {
      state = state.copyWith(errorMessage: '郵便番号を入力してください');
      return;
    }

    try {
      state = state.copyWith(isLoading: true, errorMessage: null);
      final address = await _repository.getAddress(state.postalCode);
      state = state.copyWith(
        isLoading: false,
        address: address,
        errorMessage: null,
      );
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        errorMessage: e.toString(),
      );
    }
  }
}

【After】

postal_code_repository.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../states/postal_code_state.dart';
import '../repositories/postal_code_repository.dart';

part 'postal_code_provider.g.dart';


class PostalCode extends _$PostalCode {
  
  PostalCodeState build() => const PostalCodeState();

  void updatePostalCode(String postalCode) {
    state = state.copyWith(postalCode: postalCode);
  }

  Future<void> searchAddress() async {
    if (state.postalCode.isEmpty) {
      state = state.copyWith(errorMessage: '郵便番号を入力してください');
      return;
    }

    try {
      state = state.copyWith(isLoading: true, errorMessage: null);
      final repository = ref.read(postalCodeRepositoryProvider);
      final address = await repository.getAddress(state.postalCode);
      state = state.copyWith(
        isLoading: false,
        address: address,
        errorMessage: null,
      );
    } catch (e) {
      state = state.copyWith(
        isLoading: false,
        errorMessage: e.toString(),
      );
    }
  }
}

※ 追加後に$ flutter pub run build_runner buildを実行する。

参考資料

3. Flutterで作るToDoリスト型のUI実装サンプルアプリ

https://github.com/fumiyasac/todo_style_example_app

動画で解説されていたサンプルを元にして、Riverpod + Firestoreを連携する処理部分を、最新バージョンRiverpodを利用して書き直し&リファクタリングを実施したサンプルになります。

【参考にした動画サンプル】

https://www.youtube.com/watch?v=X7TTK9T77fo

想定アーキテクチャ概要

画面スクリーンショット

構築画面1 構築画面2
構築画面1 構築画面2

サンプル構築の際に利用したもの

【サンプルで利用したパッケージ】

ポイント解説

【サンプルにおける重要部分をまとめたノート】

新旧の書き方を比較する

(1) StateProvider定義部分の書き換え

元のサンプルでは、ラジオボタン・日付選択・時間選択処理でStateProviderを利用していました。
しかしながら、Riverpod2.0以降ではこの書き方は非推奨となったため、このサンプルでは下記の様な形で書き直しを実施しています。

【Before】

final radioProvider = StateProvider<int>((ref) {
  return 0;
});

final dateProvider = StateProvider<String>((ref) {
  return "dd/mm/yy";
});

final timeProvider = StateProvider<String>((ref) {
  return "hh : mm";
});

// 👉 ラジオボタン選択処理用のProvider利用箇所での処理
// ref.read(radioProvider.notifier).update((state) => 1);

// 👉 日付選択処理用のProvider利用箇所での処理
// ref.read(dateProvider.notifier).update((state) => format.format(getDate));

// 👉 時間選択処理用のProvider利用箇所での処理
// ref.read(timeProvider.notifier).update((state) => getTime.format(context));

【After】

final radioProvider = NotifierProvider<RadioNotifier, int>(RadioNotifier.new);
class RadioNotifier extends Notifier<int> {
  
  int build() => 0;
  void update(int radioValue) {
    state = radioValue;
  }
}

final dateProvider = NotifierProvider<DateNotifier, String>(DateNotifier.new);
class DateNotifier extends Notifier<String> {
  
  String build() => "dd/mm/yy";
  void update(String dateValue) {
    state = dateValue;
  }
}

final timeProvider = NotifierProvider<TimeNotifier, String>(TimeNotifier.new);
class TimeNotifier extends Notifier<String> {
  
  String build() => "hh : mm";
  void update(String timeValue) {
    state = timeValue;
  }
}

// 👉 ラジオボタン選択処理用のProvider利用箇所での処理
// ref.read(radioProvider.notifier).update(1);

// 👉 日付選択処理用のProvider利用箇所での処理
// ref.read(dateProvider.notifier).update(format.format(getDate));

// 👉 時間選択処理用のProvider利用箇所での処理
// ref.read(timeProvider.notifier).update("hh : mm");

(2) Todo処理部分の構築に関して

Todo一覧表示をする処理についてもStateNotifierを利用したため、Streamを利用する形に書き換えを実施しています。

① Firestore内でのDocument定義

② todo_repository.dart

import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:todo_style_example_app/model/todo_model.dart';

class TodoRepository {
  final todoCollection = FirebaseFirestore.instance.collection('todoApp');

  // 👉 FirestoreからのDocument取得処理についてはStreamを利用して取得する形に変更しています。
  // ※ View側でToDoデータ一覧を取得して表示する処理についても`StreamBuilder`を使用しています。
  Stream<List<TodoModel>> fetchTasks() {
    return todoCollection.snapshots()
      .map((event) =>
        event.docs.map((snapshot) => TodoModel.fromSnapshot(snapshot)).toList()
      );
  }

  void addNewTask(TodoModel model) {
    todoCollection.add(model.toMap());
  }

  void updateTask(String? docID, bool? valueUpdate) {
    todoCollection.doc(docID).update({
      'isDone': valueUpdate,
    });
  }

  void deleteTask(String? docID) {
    todoCollection.doc(docID).delete();
  }
}

③ todo_provider.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:todo_style_example_app/repository/todo_repository.dart';

final todoProvider = NotifierProvider<TodoRepositoryNotifier, TodoRepository>(TodoRepositoryNotifier.new);
class TodoRepositoryNotifier extends Notifier<TodoRepository> {
  
  TodoRepository build() => TodoRepository();
}

4. Infinite Scroll & Pull To Refreshを利用した一覧表示サンプル

※この章では、Riverpod + Riverpod Generatorを利用した書き方のみの紹介となります。

https://github.com/fumiyasac/commerce_style_sample_app

想定アーキテクチャ概要

画面スクリーンショット

構築画面1 構築画面2
構築画面1 構築画面2

サンプル構築の際に利用したもの

【サンプルで利用したパッケージ】

【サンプル表示で利用したAPI】

ポイント解説

【サンプルにおける重要部分をまとめたノート】

ViewModelクラスの構築に関して

ViewModelクラスのみ、Riverpodのコード自動生成を利用して作成しています。

【自動生成実行コマンド】

$ dart run build_runner build

① product_state.dart

import 'package:commerce_style_sample_app/models/product.dart';

class ProductState {
  final List<Product> products;
  final bool isLoading;
  final bool hasError;
  final String? errorMessage;
  final bool hasMoreData;
  final int currentSkip;

  ProductState({
    required this.products,
    required this.isLoading,
    required this.hasError,
    this.errorMessage,
    required this.hasMoreData,
    required this.currentSkip,
  });

  // 初期状態
  factory ProductState.initial() {
    return ProductState(
      products: [],
      isLoading: false,
      hasError: false,
      errorMessage: null,
      hasMoreData: true,
      currentSkip: 0,
    );
  }

  // 状態のコピーを作る
  ProductState copyWith({
    List<Product>? products,
    bool? isLoading,
    bool? hasError,
    String? errorMessage,
    bool? hasMoreData,
    int? currentSkip,
  }) {
    return ProductState(
      products: products ?? this.products,
      isLoading: isLoading ?? this.isLoading,
      hasError: hasError ?? this.hasError,
      errorMessage: errorMessage ?? this.errorMessage,
      hasMoreData: hasMoreData ?? this.hasMoreData,
      currentSkip: currentSkip ?? this.currentSkip,
    );
  }
}

② product_view_model.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:commerce_style_sample_app/view_models/product_state.dart';
import 'package:commerce_style_sample_app/services/product_service.dart';

part 'product_view_model.g.dart';

// ServiceProviderを定義する

ProductService productService(Ref ref) {
  return ProductService();
}

// ViewModelProviderを定義する
// 👉 riverpod_generatorでの自動生成を利用して作成する

class ProductViewModel extends _$ProductViewModel {

  
  ProductState build() {
    // 最初はProductStateの初期状態を返す
    return ProductState.initial();
  }

  Future<void> fetchProducts({bool refresh = false}) async {
    // Refreshが実行された場合はProductStateを初期状態にリセットする
    if (refresh) {
      state = ProductState.initial();
    }
    // 既にLoading中、または、追加のデータがない場合は以降の処理を実施しない
    if (state.isLoading || (!state.hasMoreData && !refresh)) {
      return;
    }
    // ProductStateをLoading中状態に更新する
    state = state.copyWith(isLoading: true, hasError: false);
    try {
      // productServiceでの処理を実行するために、productServiceProviderを利用する
      final productService = ref.read(productServiceProvider);
      final result = await productService.getProducts(
        limit: 10,
        skip: state.currentSkip,
      );

      if (result.products.isEmpty) {
        // 追加のデータがない場合は終了フラグを立てる
        state = state.copyWith(
          isLoading: false,
          hasMoreData: false,
        );
      } else {
        // 新たに取得したプロダクト一覧情報が後に来るように、既存のリストに追加する
        final updatedProducts = [...state.products, ...result.products];
        state = state.copyWith(
          products: updatedProducts,
          isLoading: false,
          currentSkip: state.currentSkip + 10,
          hasMoreData: state.currentSkip + 10 < result.total,
        );
      }
    } catch (e) {
      // データ取得処理時にエラーが発生した場合は、エラー状態に更新する
      state = state.copyWith(
        isLoading: false,
        hasError: true,
        errorMessage: e.toString(),
      );
    }
  }
}

③ product_view_model_test.dart

import 'package:commerce_style_sample_app/models/product.dart';
import 'package:commerce_style_sample_app/models/product_response.dart';
import 'package:commerce_style_sample_app/services/product_service.dart';
import 'package:commerce_style_sample_app/view_models/product_view_model.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:mockito/annotations.dart';

([MockSpec<ProductService>()])

import 'product_view_model_test.mocks.dart';

void main() {
group('PostalCodeProvider Tests', () {
late ProviderContainer container;
late MockProductService mockProductService;

    setUp(() {
      mockProductService = MockProductService();
      container = ProviderContainer(
        overrides: [
          productServiceProvider.overrideWithValue(mockProductService),
        ],
      );
      addTearDown(container.dispose);
    });

    test('should initial state should be correct', () {
      final state = container.read(productViewModelProvider);
      expect(state.products, isEmpty);
      expect(state.isLoading, isFalse);
      expect(state.hasError, isFalse);
      expect(state.errorMessage, isNull);
      expect(state.hasMoreData, isTrue);
      expect(state.currentSkip, 0);
    });

    test('should fetch successfully and have next page', () async {
      final mockProductList = [
        Product(
          id: 1,
          title: "Title Example No.1",
          description: "Description Example No.1",
          price: 9.99,
          discountPercentage: 7.17,
          rating: 4.94,
          stock: 5,
          thumbnail: "https://cdn.dummyjson.com/products/images/thumbnail.png"
        ),

        // ... (途中省略) ...

        Product(
          id: 10,
          title: "Title Example No.10",
          description: "Description Example No.10",
          price: 9.99,
          discountPercentage: 7.17,
          rating: 4.94,
          stock: 5,
          thumbnail: "https://cdn.dummyjson.com/products/images/thumbnail.png"
        ),
      ];
      final mockProductResponse = ProductResponse(
          products: mockProductList,
          total: 194,
          skip: 0,
          limit: 10
      );

      when(mockProductService.getProducts(limit: 10, skip: 0)).thenAnswer(
            (_) async => mockProductResponse,
      );

      final notifier = container.read(productViewModelProvider.notifier);
      await notifier.fetchProducts();

      final state = container.read(productViewModelProvider);
      expect(state.products.length, 10);
      expect(state.isLoading, isFalse);
      expect(state.hasError, isFalse);
      expect(state.errorMessage, isNull);
      expect(state.hasMoreData, isTrue);
      expect(state.currentSkip, 10);
      verify(mockProductService.getProducts(limit: 10, skip: 0)).called(1);
    });

    test('should fetch successfully and stop next fetch', () async {
      final mockProductResponse = ProductResponse(
          products: [],
          total: 194,
          skip: 0,
          limit: 10
      );

      when(mockProductService.getProducts(limit: 10, skip: 0)).thenAnswer(
            (_) async => mockProductResponse,
      );

      final notifier = container.read(productViewModelProvider.notifier);
      await notifier.fetchProducts();

      final state = container.read(productViewModelProvider);
      expect(state.products.length, 0);
      expect(state.isLoading, isFalse);
      expect(state.hasError, isFalse);
      expect(state.errorMessage, isNull);
      expect(state.hasMoreData, isFalse);
      expect(state.currentSkip, 0);
      verify(mockProductService.getProducts(limit: 10, skip: 0)).called(1);
    });

    test('should handle error during product fetch', () async {
      const errorMessage = 'プロダクトデータの取得に失敗しました';

      when(mockProductService.getProducts(limit: 10, skip: 0)).thenThrow(
        Exception(errorMessage),
      );

      final notifier = container.read(productViewModelProvider.notifier);
      await notifier.fetchProducts();

      final state = container.read(productViewModelProvider);
      expect(state.products.length, 0);
      expect(state.isLoading, isFalse);
      expect(state.hasError, isTrue);
      expect(state.errorMessage, "Exception: プロダクトデータの取得に失敗しました");
      expect(state.hasMoreData, isTrue);
      expect(state.currentSkip, 0);
      verify(mockProductService.getProducts(limit: 10, skip: 0)).called(1);
    });
});
}

5. Firestoreを利用した書籍メモ型サンプル

※この章では、Riverpod + Riverpod Generatorを利用した書き方のみの紹介となります。

https://github.com/fumiyasac/riverpod_firestore_example

Flutter & Riverpod & Firestore & Freezedを利用した簡易的な書籍メモ管理アプリサンプルになります。サンプルとしましては、参考書を登録して、任意の参考書に紐づくコメントを複数件書き込む事ができるだけのシンプルなものになります。

想定アーキテクチャ概要

画面スクリーンショット

構築画面1 構築画面2
構築画面1 構築画面2
構築画面3 構築画面4
構築画面3 構築画面4

サンプル構築の際に利用したもの

【サンプルで利用したパッケージ】

各種クラスの構築に関して

【自動生成実行コマンド】

$ dart run build_runner build

【Firestore内でのDocument定義】

① Model

book.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

// 自動生成されたファイルの読み込みをする
part 'book.freezed.dart';
part 'book.g.dart';

// コードを自動生成をするために「@freezed」を記述する

class Book with _$Book {

  // プロパティを定義する
  const factory Book({
    required String id,
    required String title,
    required String author,
    required String summary,
    required String isbn,
    required String userId,
    required DateTime createdAt,
  }) = _Book;

  // JSON形式で受け取るためのコードを定義する
  factory Book.fromJson(Map<String, dynamic> json) => _$BookFromJson(json);

  // Firestoreからデータを受け取ってJSON形式に変換する
  factory Book.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map<String, dynamic>;
    return Book.fromJson({
      ...data,
      'id': doc.id,
      'createdAt': (data['createdAt'] as Timestamp).toDate().toIso8601String(),
    });
  }
}
comment.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:freezed_annotation/freezed_annotation.dart';

// 自動生成されたファイルの読み込みをする
part 'comment.freezed.dart';
part 'comment.g.dart';

// コードを自動生成をするために「@freezed」を記述する

class Comment with _$Comment {

  // プロパティを定義する
  const factory Comment({
    required String id,
    required String bookId,
    required String userId,
    required String content,
    required DateTime createdAt,
  }) = _Comment;

  // JSON形式で受け取るためのコードを定義する
  factory Comment.fromJson(Map<String, dynamic> json) => _$CommentFromJson(json);

  // Firestoreからデータを受け取ってJSON形式に変換する
  factory Comment.fromFirestore(DocumentSnapshot doc) {
    final data = doc.data() as Map<String, dynamic>;
    return Comment.fromJson({
      ...data,
      'id': doc.id,
      'createdAt': (data['createdAt'] as Timestamp).toDate().toIso8601String(),
    });
  }
}

② Repository

book_repository.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/book.dart';

part 'book_repository.g.dart';


BookRepository bookRepository(Ref ref) {
  return BookRepository(FirebaseFirestore.instance);
}

class BookRepository {
  final FirebaseFirestore _firestore;

  BookRepository(this._firestore);

  Future<List<Book>> getBooks() async {
    final snapshot = await _firestore
        .collection('books')
        .orderBy('createdAt', descending: true)
        .get();
    return snapshot.docs.map((doc) => Book.fromFirestore(doc)).toList();
  }

  Future<void> addBook(String title, String author, String summary, String isbn, String userId) async {
    await _firestore.collection('books').add({
      'title': title,
      'author': author,
      'summary': summary,
      'isbn': isbn,
      'userId': userId,
      'createdAt': FieldValue.serverTimestamp(),
    });
  }

  Future<void> deleteBook(String id) async {
    await _firestore.collection('books').doc(id).delete();
  }
}
comment_repository.dart
import 'package:cloud_firestore/cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/comment.dart';

part 'comment_repository.g.dart';


CommentRepository commentRepository(Ref ref) {
  return CommentRepository(FirebaseFirestore.instance);
}

class CommentRepository {
  final FirebaseFirestore _firestore;

  CommentRepository(this._firestore);

  Future<List<Comment>> getComments(String bookId) async {
    final snapshot = await _firestore
        .collection('comments')
        // MEMO: このままだとエラーとなるので、インデックスを追加する必要有
        // 👉 エラーメッセージに表示されているリンクを押下して設定する
        .where('bookId', isEqualTo: bookId)
        .orderBy('createdAt', descending: true)
        .get();
    return snapshot.docs.map((doc) => Comment.fromFirestore(doc)).toList();
  }

  Future<void> addComment(String bookId, String userId, String content) async {
    await _firestore
      .collection('comments').add({
        'bookId': bookId,
        'userId': userId,
        'content': content,
        'createdAt': FieldValue.serverTimestamp(),
      });
  }

  Future<void> deleteComment(String bookId) async {
    final snapshot = await _firestore
      .collection('comments')
      .where('bookId', isEqualTo: bookId)
      .get();
    for (var doc in snapshot.docs) {
      doc.reference.delete();
    }
  }
}

③ ViewModel

book_view_model.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:riverpod_firestore_example/repositories/comment_repository.dart';
import '../models/book.dart';
import '../repositories/book_repository.dart';

part 'book_view_model.g.dart';


class BookViewModel extends _$BookViewModel {
  
  Future<List<Book>> build() async {
    return ref.watch(bookRepositoryProvider).getBooks();
  }

  Future<void> addBook(String title, String author, String summary, String isbn, String userId) async {
    await ref.read(bookRepositoryProvider).addBook(title, author, summary, isbn, userId);
    ref.invalidateSelf();
  }

  Future<void> deleteBook(String id) async {
    await ref.read(bookRepositoryProvider).deleteBook(id);
    await ref.read(commentRepositoryProvider).deleteComment(id);
    ref.invalidateSelf();
  }
}
comment_view_model.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import '../models/comment.dart';
import '../repositories/comment_repository.dart';

part 'comment_view_model.g.dart';


class CommentViewModel extends _$CommentViewModel {
  
  Future<List<Comment>> build(String bookId) async {
    return ref.watch(commentRepositoryProvider).getComments(bookId);
  }

  Future<void> addComment(String bookId, String userId, String content) async {
    await ref.read(commentRepositoryProvider).addComment(bookId, userId, content);
    ref.invalidateSelf();
  }
}

④ Repository Unit Tests

book_repository_test.dart
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_firestore_example/repositories/book_repository.dart';

void main() {
  late FakeFirebaseFirestore fakeFirestore;
  late BookRepository repository;

  setUp(() {
    fakeFirestore = FakeFirebaseFirestore();
    repository = BookRepository(fakeFirestore);
  });

  group('BookRepository', () {
    test('getBooks returns list of books', () async {
      // テストデータの準備
      final testData1 = {
        'id': '1',
        'title': 'Test Book No.1',
        'author': 'Test Author No.1',
        'summary': 'Test Summary No.1',
        'isbn': '1111111111111',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
        'comments': [],
      };
      final testData2 = {
        'id': '2',
        'title': 'Test Book No.2',
        'author': 'Test Author No.2',
        'summary': 'Test Summary No.2',
        'isbn': '2222222222222',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
        'comments': [],
      };
      final testData3 = {
        'id': '3',
        'title': 'Test Book No.3',
        'author': 'Test Author No.3',
        'summary': 'Test Summary No.3',
        'isbn': '3333333333333',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
        'comments': [],
      };
      await fakeFirestore.collection('books').doc('1').set(testData1);
      await fakeFirestore.collection('books').doc('2').set(testData2);
      await fakeFirestore.collection('books').doc('3').set(testData3);

      // 追加された本を取得
      final books = await repository.getBooks();

      expect(books.length, 3);
      expect(books.first.title, 'Test Book No.3');
      expect(books.first.author, 'Test Author No.3');
      expect(books.last.title, 'Test Book No.1');
      expect(books.last.author, 'Test Author No.1');
    });

    test('addBook successfully adds a book', () async {

      // 新規の本を作成(IDは空文字列または任意の文字列)
      await repository.addBook('New Book', 'New Author', 'New Summary', '123456789012', 'exampleUserId');

      // 追加された本を取得
      final books = await repository.getBooks();

      expect(books.length, 1);
      expect(books.first.title, 'New Book');
      expect(books.first.author, 'New Author');
      expect(books.first.id.isNotEmpty, true);
    });

    test('deleteBook successfully deletes a book', () async {

      // テスト用の本を追加
      final docRef = await fakeFirestore.collection('books').add({
        'title': 'Test Book',
        'author': 'Test Author',
        'summary': 'Test Summary',
        'isbn': '123456789012',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
        'comments': [],
      });

      // 削除前の確認
      var books = await repository.getBooks();
      expect(books.length, 1);

      // 本を削除
      await repository.deleteBook(docRef.id);

      // 削除後の確認
      books = await repository.getBooks();
      expect(books.length, 0);

      // ドキュメントが実際に削除されたことを確認
      final docSnapshot = await fakeFirestore.collection('books').doc(docRef.id).get();
      expect(docSnapshot.exists, false);
    });
  });
}
comment_repository_test.dart
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_firestore_example/repositories/comment_repository.dart';

void main() {
  late FakeFirebaseFirestore fakeFirestore;
  late CommentRepository repository;

  setUp(() {
    fakeFirestore = FakeFirebaseFirestore();
    repository = CommentRepository(fakeFirestore);
  });

  group('CommentRepository', () {
    test('getComments returns list of comments', () async {
      // テストデータの準備
      final testData1 = {
        'id': '1',
        'bookId': '9999',
        'content': 'Test Content No.1',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
      };
      final testData2 = {
        'id': '2',
        'bookId': '10000',
        'content': 'Test Content No.2',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
      };
      final testData3 = {
        'id': '3',
        'bookId': '9999',
        'content': 'Test Content No.3',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
      };
      await fakeFirestore.collection('comments').doc('1').set(testData1);
      await fakeFirestore.collection('comments').doc('2').set(testData2);
      await fakeFirestore.collection('comments').doc('3').set(testData3);

      // 追加されたコメントを取得
      final books = await repository.getComments("9999");

      expect(books.length, 2);
      expect(books.first.content, 'Test Content No.3');
      expect(books.last.content, 'Test Content No.1');
    });

    test('addComment successfully adds a comment', () async {

      // 新規のコメントを作成(IDは空文字列または任意の文字列)
      await repository.addComment("9999", "exampleUserId", "New Test Content");

      // 追加された本を取得
      final comment = await repository.getComments("9999");

      expect(comment.length, 1);
      expect(comment.first.bookId, '9999');
      expect(comment.first.content, 'New Test Content');
      expect(comment.first.id.isNotEmpty, true);
    });

    test('deleteComment successfully deletes comments', () async {

      // テスト用のコメントを追加
      final testData1 = {
        'id': '1',
        'bookId': '9999',
        'content': 'Test Content No.1',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
      };
      final testData2 = {
        'id': '2',
        'bookId': '10000',
        'content': 'Test Content No.2',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
      };
      final testData3 = {
        'id': '3',
        'bookId': '9999',
        'content': 'Test Content No.3',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
      };
      await fakeFirestore.collection('comments').doc('1').set(testData1);
      await fakeFirestore.collection('comments').doc('2').set(testData2);
      await fakeFirestore.collection('comments').doc('3').set(testData3);

      final docRef = fakeFirestore.collection('comments');

      // 削除前の確認
      var comments = await repository.getComments('9999');
      expect(comments.length, 2);

      // コメントを削除
      await repository.deleteComment('9999');

      // 削除後の確認
      comments = await repository.getComments('9999');
      expect(comments.length, 0);

      // ドキュメントが実際に削除されたことを確認
      final docSnapshot = await fakeFirestore.collection('comments').doc(docRef.id).get();
      expect(docSnapshot.exists, false);
    });
  });
}

⑤ ViewModel Unit Tests

book_view_model_test.dart
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_firestore_example/models/book.dart';
import 'package:riverpod_firestore_example/repositories/book_repository.dart';
import 'package:riverpod_firestore_example/repositories/comment_repository.dart';
import 'package:riverpod_firestore_example/view_models/book_view_model.dart';

void main() {
  late FakeFirebaseFirestore fakeFirestore;
  late BookRepository bookRepository;
  late CommentRepository commentRepository;
  late ProviderContainer container;

  setUp(() async {
    fakeFirestore = FakeFirebaseFirestore();
    // テスト用のデータを事前に追加
    await fakeFirestore.collection('books').add({
      'id': '1',
      'title': 'Test Book No.1',
      'author': 'Test Author No.1',
      'summary': 'Test Summary No.1',
      'isbn': '1111111111111',
      'userId': 'exampleUserId',
      'createdAt': DateTime.now(),
      'comments': [],
    });
    await fakeFirestore.collection('books').add({
      'id': '2',
      'title': 'Test Book No.2',
      'author': 'Test Author No.2',
      'summary': 'Test Summary No.2',
      'isbn': '2222222222222',
      'userId': 'exampleUserId',
      'createdAt': DateTime.now(),
      'comments': [],
    });
    bookRepository = BookRepository(fakeFirestore);
    commentRepository = CommentRepository(fakeFirestore);
    container = ProviderContainer(
      overrides: [
        bookRepositoryProvider.overrideWith((ref) => bookRepository),
        commentRepositoryProvider.overrideWith((ref) => commentRepository),
      ],
    );
  });

  group('BookViewModel with FakeFirestore Tests', () {
    test('should load books from Firestore', () async {
      final booksAsync = container.read(bookViewModelProvider);
      expect(booksAsync, isA<AsyncLoading<List<Book>>>());
      final books = await container.read(bookViewModelProvider.future);
      expect(books, isNotEmpty);
      expect(books.first.title, 'Test Book No.2');
    });

    test('should add book to Firestore', () async {
      await container.read(bookViewModelProvider.notifier)
          .addBook('New Test Book', 'New Test Author', 'New Test Summary', '123456789012', 'exampleUserId');
      final querySnapshot = await fakeFirestore.collection('books')
          .where('title', isEqualTo: 'New Test Book')
          .get();
      expect(querySnapshot.docs, hasLength(1));
      expect(querySnapshot.docs.first.data()['author'], 'New Test Author');
      expect(querySnapshot.docs.first.data()['summary'], 'New Test Summary');
      expect(querySnapshot.docs.first.data()['isbn'], '123456789012');
      expect(querySnapshot.docs.first.data()['userId'], 'exampleUserId');
    });

    test('should delete book from Firestore', () async {
      final docRef = await fakeFirestore.collection('books').add({
        'id': '9999',
        'title': 'Book to Delete',
        'author': 'Author to Delete',
        'userId': 'exampleUserId',
        'createdAt': DateTime.now(),
      });
      await container.read(bookViewModelProvider.notifier)
          .deleteBook(docRef.id);
      final deletedBookDoc = await fakeFirestore.collection('books')
          .doc(docRef.id)
          .get();
      expect(deletedBookDoc.exists, false);
    });
  });
}
comment_view_model_test.dart
import 'package:fake_cloud_firestore/fake_cloud_firestore.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:riverpod_firestore_example/models/comment.dart';
import 'package:riverpod_firestore_example/repositories/comment_repository.dart';
import 'package:riverpod_firestore_example/view_models/comment_view_model.dart';

void main() {
  late FakeFirebaseFirestore fakeFirestore;
  late CommentRepository commentRepository;
  late ProviderContainer container;

  setUp(() async {
    fakeFirestore = FakeFirebaseFirestore();
    // テスト用のデータを事前に追加
    await fakeFirestore.collection('comments').add({
      'id': '1',
      'bookId': '9999',
      'content': 'Test Content No.1',
      'userId': 'exampleUserId',
      'createdAt': DateTime.now(),
    });
    await fakeFirestore.collection('comments').add({
      'id': '2',
      'bookId': '10000',
      'content': 'Test Content No.2',
      'userId': 'exampleUserId',
      'createdAt': DateTime.now(),
    });
    await fakeFirestore.collection('comments').add({
      'id': '3',
      'bookId': '9999',
      'content': 'Test Content No.3',
      'userId': 'exampleUserId',
      'createdAt': DateTime.now(),
    });
    commentRepository = CommentRepository(fakeFirestore);
    container = ProviderContainer(
      overrides: [
        commentRepositoryProvider.overrideWith((ref) => commentRepository),
      ],
    );
  });

  group('CommentViewModel with FakeFirestore Tests', () {
    test('should load comment from Firestore', () async {
      final bookId = '9999';
      final commentAsync = container.read(commentViewModelProvider(bookId));
      expect(commentAsync, isA<AsyncLoading<List<Comment>>>());
      final comments = await container.read(commentViewModelProvider(bookId).future);
      expect(comments, isNotEmpty);
      expect(comments.first.content, 'Test Content No.3');
    });

    test('should add book to Firestore', () async {
      final bookId = '9999';
      await container.read(commentViewModelProvider(bookId).notifier)
          .addComment(bookId, 'exampleUserId', 'New Content');
      final querySnapshot = await fakeFirestore.collection('comments')
          .where('content', isEqualTo: 'New Content')
          .get();
      expect(querySnapshot.docs, hasLength(1));
      expect(querySnapshot.docs.first.data()['bookId'], '9999');
      expect(querySnapshot.docs.first.data()['userId'], 'exampleUserId');
    });
  });
}

6. まとめ

Riverpod 2.x系からは状態管理の実装方法が大きく変化しましたが、これにより記述がよりシンプルになり、Riverpod Generatorを活用したコード自動生成の恩恵を最大限に受けられるようになる様に感じました。

今回紹介した事例が、皆さんのプロジェクトでの状態管理実装の参考になれば幸いです。

1

Discussion

JboyHashimotoJboyHashimoto

テストコードまで書いてある。ありがたやー👏
riverpod2.6.0以降は、(Ref ref)になっちゃいましたけどね😅
updateに追いつくの大変ですね。

Fumiya SakaiFumiya Sakai

読んで頂きありがとうございます!
下記Warning部分の修正を、記事内のコードに反映しておきました。

ログインするとコメントできます