【ProviderとRiverpodの違い②】一度に 1 つの値しか吐き出さない

【ProviderとRiverpodの違い②】一度に 1 つの値しか吐き出さない

はじめに

前回に引き続き、今回もRiverpodのモチベーションについて解説していきます。

今回の記事では、「Provider は一度に1つの値しか吐き出さない」というセクションを掘り下げていきます。

Provider は一度に 1 つの値しか吐き出さない

公式ドキュメントの説明

外部の RESTful API を読み取るとき、最後に読み取った値を表示しながら新しい呼び出しで次の値を読み込むことが一般的です。

Riverpod では、AsyncValue の API を介して、前のデータ値と新しいロード中の値の 2 つの値を同時に吐き出します:


@riverpod
Future<List<Item>> itemsApi(ItemsApiRef ref) async {
  final client = Dio();
  final result = await client.get<List<dynamic>>('your-favorite-api');
  final parsed = [...result.data!.map((e) => Item.fromJson(e as Json))];
  return parsed;
}

@riverpod
List<Item> evenItems(EvenItemsRef ref) {
  final asyncValue = ref.watch(itemsApiProvider);
  if (asyncValue.isReloading) return [];
  if (asyncValue.hasError) return const [Item(id: -1)];

  final items = asyncValue.requireValue;

  return [...items.whereIndexed((index, element) => index.isEven)];
}

上記のスニペットでは、evenItemsProvider を監視すると次の効果が得られます:

  1. 最初はリクエストが行われ、空のリストが取得されます。
  2. 次に、エラーが発生した場合、[Item(id: -1)]が取得されます。
  3. 次に、pull to refresh ロジックでリクエストを再試行します(例:ref.invalidate)。
  4. 最初の provider を再読み込みする間、2 番目の provider は依然として[Item(id: -1)]を公開します。
  5. 今回は、解析されたデータが正しく受信され、偶数のアイテムが正しく返されます。

Provider では、上記の機能は達成できず、回避策も困難です。

一度に 1 つの値しか吐き出さないProviderの例

Providerは基本的に、1つの値のみを保持して公開します。

これは多くのシナリオで十分機能しますが、非同期データの取り扱いにおいてはいくつかの課題があります。

特に、外部APIからデータを取得する際、最新のデータをロードする過程で既存のデータを表示したい場合などです。

Providerでは、新しいデータが読み込まれるまでの間、最後に読み込んだデータしか持つことができません。

つまり、新しいデータがロードされている間、前のデータを参照する簡単な方法がありません。

具体的なコードを使って、実際のアプリケーション開発で問題となるかを見てみましょう。

ここでは、非同期データを扱う際の一般的な問題として、データの更新中に古いデータをどのように表示するか、またはエラー発生時のハンドリングをどうするかを例に取り上げます。

以下のコードは、外部APIから商品データを取得し、そのデータを表示するシンプルなアプリケーションです。

Providerを用いて、非同期にデータをフェッチし、画面に表示しています。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    MultiProvider(
      providers: [
        FutureProvider<List<Product>>(
          create: (_) => fetchProducts(),
          initialData: const [],
        ),
      ],
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Provider App',
      theme: ThemeData(
        primarySwatch: Colors.blue,
        visualDensity: VisualDensity.adaptivePlatformDensity,
      ),
      home: const HomePage(),
    );
  }
}

class Product {
  final String name;
  final double price;

  Product({required this.name, required this.price});
}

Future<List<Product>> fetchProducts() async {
  await Future.delayed(const Duration(seconds: 3));
  return [
    Product(name: "Apple", price: 1.2),
    Product(name: "Banana", price: 0.8),
    Product(name: "Cherry", price: 2.0)
  ];
}

class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    List<Product> products = context.watch<List<Product>>();

    return Scaffold(
      appBar: AppBar(title: const Text('Home Page')),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          return ListTile(
            title: Text(products[index].name),
            subtitle: Text("\\$${products[index].price}"),
          );
        },
      ),
    );
  }
}

データを更新するたびに、新しいデータがロードされるまでの間、何も表示できない、またはエラーが発生した場合に適切に対応できないという問題があります。

FutureProviderは非同期処理を簡潔に扱えるように設計されていますが、デフォルトではローディング状態やエラー状態を直接扱う機能が提供されていません。

FutureProviderはデータが利用可能になったときのみ更新され、そのデータを消費するウィジェットに通知します。

ローディング状態やエラー状態のハンドリングを行いたい場合は、setStateを使ってウィジェット内でFutureを直接扱い、状態変更を手動で管理するようなアプローチが考えられます。

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

void main() {
  runApp(
    Provider(
      create: (_) => fetchProducts(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return const MaterialApp(
      title: 'Products App',
      home: HomePage(),
    );
  }
}

class Product {
  final String name;
  final double price;

  Product(this.name, this.price);
}

Future<List<Product>> fetchProducts() async {
  await Future.delayed(const Duration(seconds: 3));
  return [
    Product("Apple", 1.2),
    Product("Banana", 0.8),
    Product("Cherry", 2.0),
  ];
}

class HomePage extends StatefulWidget {
  const HomePage({super.key});

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  List<Product>? products;
  bool isLoading = true;

  @override
  void initState() {
    super.initState();
    initFetch();
  }

  Future<void> initFetch() async {
    try {
      await fetchProducts().then((productList) {
        setState(() => products = productList);
      });
    } catch (e) {
      // ログ
    } finally {
      setState(() => isLoading = false);
    }
  }

  Widget _buildBody() {
    if (isLoading) {
      return const Center(child: CircularProgressIndicator());
    }
    if (products == null) {
      return const Center(child: Text('エラー'));
    }
    return ListView.builder(
      itemCount: products!.length,
      itemBuilder: (context, index) {
        final product = products![index];
        return ListTile(
          title: Text(product.name),
          subtitle: Text("\\$${product.price.toString()}"),
        );
      },
    );
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Products')),
      body: _buildBody(),
    );
  }
}

Riverpodで効率的に複数データの変更を管理

Riverpodを使うことで、より効率的にデータの変更を管理できます。

特に、Riverpod ではデータの変更に応じて特定の部分だけが再描画されるため、パフォーマンスの最適化が図れます。

AsyncValueStateNotifier などの機能を活用することで、データの変更が必要なコンポーネントにのみ伝播され、必要最小限の再描画が行われる点が、setState に比べて良いです。

例えば、データが追加された際に、setState を使うとリスト全体が再描画される可能性がありますが、Riverpodの StateNotifier を使えば、変更のあったデータに応じた部分だけを更新できるため、パフォーマンスの面で効率的となります。

import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(const ProviderScope(child: MyApp()));
}

class MyApp extends ConsumerWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return const MaterialApp(
      title: 'Riverpod App',
      home: Home(),
    );
  }
}

class Product {
  final String name;
  final double price;

  Product(this.name, this.price);
}

final productsProvider = FutureProvider<List<Product>>((ref) async {
  await Future.delayed(const Duration(seconds: 3));
  return [
    Product("Apple", 1.2),
    Product("Banana", 0.8),
    Product("Cherry", 2.0),
  ];
});

class Home extends ConsumerWidget {
  const Home({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    AsyncValue<List<Product>> products = ref.watch(productsProvider);

    return Scaffold(
      appBar: AppBar(title: const Text("Products")),
      body: products.when(
        data: (list) => ListView(
          children: list
              .map((product) => ListTile(
                    title: Text(product.name),
                    subtitle: Text("\\$${product.price.toString()}"),
                  ))
              .toList(),
        ),
        loading: () => const Center(child: CircularProgressIndicator()),
        error: (err, stack) => Center(child: Text('Error: $err')),
      ),
    );
  }
}

まとめ

これまでローカル変数として定義してローディング状態などもsetState で持たせていましたが、Riverpodを使うことで、より効率的にデータの変更を管理できるようになりました。

次回、「providerの結合が難しくエラーが発生しやすい」デュエルスタンバイ!

Anycloudではプロダクト開発の支援を行っています

プロダクト開発をお考えの方はぜひAnycloudにご相談ください。

まずは相談する

記事を書いた人

Matsuura

エンジニア

Matsuura

Twitter

Anycloudでエンジニアしてます!主にFlutterやWebフロントをやっていて、最近はバックエンドやインフラに挑戦・苦戦中。