Riverpod を使い始めて間もないころだと、Provider、StateProvider、StateNotifierProvider の違いについて混乱する人もいるのではないでしょうか。

この記事では、上記の違いについて説明します。

事前整理:6つのプロバイダ

本題に入る前にプロバイダについてざっとおさらいします。

プロバイダは Riverpod の中心にある概念です。

プロバイダは Riverpod において中心的な役割を担っています。 プロバイダはあるステート(状態)をラップするためのオブジェクトであり、その監視を可能にしてくれます。

https://riverpod.dev/ja/docs/concepts/providers

そして Riverpod には6つのプロバイダがあります。

  1. Provider
  2. StateProvider
  3. FutureProvider
  4. StreamProvider
  5. StateNotifierProvider
  6. ChangeNotifierProvider

このうち FutureProvider は Future 型のステートを、StreamProvider は Stream 型のステートを生成するであろうことはその名称からわかります。

そして ChangeNotifierProvider は以下の注意書きの通りで、基本的に使用することはないでしょう。

CAUTION

拡張性が求められるアプリの開発に ChangeNotifierProvider を使用することはおすすめできません。 ミュータブル(可変)なステートが様々な問題を引き起こす可能性があるためです。 基本的には package:provider からの移行を容易にするため、そして Navigator 2.0 系のパッケージでの使用など Flutter 特有のユースケースに対応するために存在しています

そうして Provider と StateProvider と StateNotifierProvider の3つが残りました。これらの違いはなんでしょうか。

本題:Provider と StateProvider と StateNotifierProvider の違い

端的に結論から書きます。

  • Provider の値は変更することはできません。この点において StateProvider そして StateNotifierProvider とは異なります。
  • StateProvider と StateNotifierProvider どちらも実現できることは同じです。ただし実装方法が変わります。

int 型のカウンターを実装して見比べる

int 型の数字をステートとして保持する場合、それぞれのプロバイダーでどのように使用するか確かめてみましょう。

counter_state.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';

// --------
// Provider
// --------

final counterProvider = Provider<int>((ref) {
  return 0;
});

// -------------
// StateProvider
// -------------

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

// ---------------------
// StateNotifierProvider
// ---------------------

class CounterStateNotifier extends StateNotifier<int> {
  CounterStateNotifier() : super(0);

  void increment() {
    state++;
  }
}

final counterStateNotifierProvider = StateNotifierProvider<CounterStateNotifier, int>((ref) {
  return CounterStateNotifier();
});

main.dart

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

import 'counter_state.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: MyHomePage());
  }
}

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Provider
    final int pCounter = ref.watch(counterProvider);

    // StateProvider
    final int spCounter = ref.watch(counterStateProvider);
    final StateController<int> spCounterState = ref.watch(counterStateProvider.state);

    // StateNotifierProvider
    final int snpCounter = ref.watch(counterStateNotifierProvider);
    final CounterStateNotifier snpCounterNotifier = ref.watch(counterStateNotifierProvider.notifier);

    return Scaffold(
      body: SafeArea(
        child: Column(
          children: <Widget>[
            // Provider から取得したステートを表示
            Row(
              children: [
                Text('$pCounter'),
              ],
            ),
            // StateProvider から取得したステートを表示・更新
            Row(
              children: [
                Text('$spCounter'),
                ElevatedButton(
                  onPressed: () {
                    spCounterState.state++;
                    // 他にも色々な書き方ができます
                    // spCounterState.update((state) => state + 1);
                    // spCounterState.update((state) => spCounter + 1);
                    // spCounterState.update((state) => spCounterState.state + 1);
                    // spCounterState.state = spCounter + 1;
                    // spCounterState.state = spCounterState.state + 1;
                  },
                  child: const Text('+'),
                )
              ],
            ),
            // StateNotifierProvider から取得したステートを表示・更新
            Row(
              children: [
                Text('$snpCounter'),
                ElevatedButton(
                  onPressed: () {
                    snpCounterNotifier.increment();
                  },
                  child: const Text('+'),
                )
              ],
            ),
          ],
        ),
      ),
    );
  }
}

Provider

Provider はステートを更新するすべがないため、単にステートを表示することしかできません。

// Provider
final int pCounter = ref.watch(counterProvider);

...

// Provider から取得したステートを表示
Row(
  children: [
    Text('$pCounter'),
  ],
),

StateProvider

StateProvider のステートの更新は state++ のように実行されています。

// StateProvider
final int spCounter = ref.watch(counterStateProvider);
final StateController<int> spCounterState = ref.watch(counterStateProvider.state);

...

// StateProvider から取得したステートを表示・更新
Row(
  children: [
    Text('$spCounter'),
    ElevatedButton(
      onPressed: () {
        spCounterState.state++;
      },
      child: const Text('+'),
    )
  ],
),

StateNotifierProvider

StateNotifierProvider のステートの更新は increment() のように実行されています。

// StateNotifierProvider
final int snpCounter = ref.watch(counterStateNotifierProvider);
final CounterStateNotifier snpCounterNotifier = ref.watch(counterStateNotifierProvider.notifier);

...

// StateNotifierProvider から取得したステートを表示・更新
Row(
  children: [
    Text('$snpCounter'),
    ElevatedButton(
      onPressed: () {
        snpCounterNotifier.increment();
      },
      child: const Text('+'),
    )
  ],
),

Freezed で定義したバリューオブジェクトのカウンターを実装して見比べる

さきほどの例示で十分伝わったかもしれませんが StateProvider と StateNotifierProvider の実装面の違いについてより際立たせたいと思います。

先ほどは単純な int 型のステートでしたが、今度は Freezed で定義した Counter 型でステートを持たせてみます。

counter.dart(自動生成されるcounter.freezed.dartcounter.g.dart の掲載は省略します)

import 'package:freezed_annotation/freezed_annotation.dart';

part 'counter.freezed.dart';
part 'counter.g.dart';

@freezed
class Counter with _$Counter {
  const factory Counter({
    required int count,
  }) = _Counter;

  factory Counter.fromJson(Map<String, Object?> json) => _$CounterFromJson(json);
}

counter_state.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';

import 'counter.dart';

// --------
// Provider
// --------

final counterProvider = Provider<Counter>((ref) {
  return const Counter(count: 0);
});

// -------------
// StateProvider
// -------------

final counterStateProvider = StateProvider<Counter>((ref) {
  return const Counter(count: 0);
});

// ---------------------
// StateNotifierProvider
// ---------------------

class CounterStateNotifier extends StateNotifier<Counter> {
  CounterStateNotifier() : super(const Counter(count: 0));

  void increment() {
    state = state.copyWith(count: state.count + 1);
  }
}

final counterStateNotifierProvider = StateNotifierProvider<CounterStateNotifier, Counter>((ref) {
  return CounterStateNotifier();
});

main.dart

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

import 'counter.dart';
import 'counter_state.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: MyHomePage());
  }
}

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Provider
    final Counter pCounter = ref.watch(counterProvider);

    // StateProvider
    final Counter spCounter = ref.watch(counterStateProvider);
    final StateController<Counter> spCounterState = ref.watch(counterStateProvider.state);

    // StateNotifierProvider
    final Counter snpCounter = ref.watch(counterStateNotifierProvider);
    final CounterStateNotifier snpCounterNotifier = ref.watch(counterStateNotifierProvider.notifier);

    return Scaffold(
      body: SafeArea(
        child: Column(
          children: <Widget>[
            // Provider から取得したステートを表示
            Row(
              children: [
                Text('${pCounter.count}'),
              ],
            ),
            // StateProvider から取得したステートを表示・更新
            Row(
              children: [
                Text('${spCounter.count}'),
                // Text('${spCounter.count}'),
                ElevatedButton(
                  onPressed: () {
                    spCounterState.update((state) => state.copyWith(count: state.count + 1));
                    // 他にも色々な書き方ができます(具体例は省略)
                  },
                  child: const Text('+'),
                )
              ],
            ),
            // StateNotifierProvider から取得したステートを表示・更新
            Row(
              children: [
                Text('${snpCounter.count}'),
                ElevatedButton(
                  onPressed: () {
                    snpCounterNotifier.increment();
                  },
                  child: const Text('+'),
                )
              ],
            ),
          ],
        ),
      ),
    );
  }
}

ステートを更新するには copyWith() を使った比較的長い記述となりますが、これがステートのクラス内に記載されるか(= StateProvider)、ウィジェット内に記載されるか(= StateNotifierProvider)でコードの見た目も変わります。

そして何より重要な違いは increment() メソッドならば必ず +1 だけされるのに対して copyWith() をウィジェットで呼ばせる場合、加算する値は 1 だけに限定されず 100 や 1000 も指定できてしまう点です。

意図しない挙動をさせないためにも、値の更新はクラスで用意した更新メソッドを通じて行わせるべきです。

このような観点から言うと StateNotifierProvider を積極的に使うべきと言えるでしょう。

おまけ:StateProvider と Provider を組み合わせる

StateProvider と Provider を組み合わせることで StateNotifierProvider のようにクラス内にフィールドとメソッドを凝集させることができます。

int 型のカウンターを実装するとして、これらでクラス内にステートと更新メソッドをまとめるように作ってみます。

counter_state.dart

import 'package:flutter_riverpod/flutter_riverpod.dart';

class CounterWrapper {
  CounterWrapper(this.count, this.stateController);

  final int count;
  final StateController<int> stateController;

  void increment() {
    stateController.state++;
  }
}

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

final counterWrapperProvider = Provider<CounterWrapper>((ref) {
  final int count = ref.watch(counterStateProvider);
  final StateController<int> stateController = ref.watch(counterStateProvider.state);
  return CounterWrapper(count, stateController);
});

main.dart

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

import 'counter_state.dart';

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

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);
  @override
  Widget build(BuildContext context) {
    return const MaterialApp(home: MyHomePage());
  }
}

class MyHomePage extends ConsumerWidget {
  const MyHomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final CounterWrapper counterWrapper = ref.watch(counterWrapperProvider);

    return Scaffold(
      body: SafeArea(
        child: Column(
          children: <Widget>[
            Row(
              children: [
                Text('${counterWrapper.count}'),
                ElevatedButton(
                  onPressed: () {
                    counterWrapper.increment();
                  },
                  child: const Text('+'),
                )
              ],
            ),
          ],
        ),
      ),
    );
  }
}