Riverpod、ProviderScopeどこに置く? ~ Providerをcontextを使って初期化する方法 ~

##概要##
Flutterの状態管理手法のRiverpod、あなたは活用されていますでしょうか。
管理したい状態をグローバルで定義できるのはとても便利ですよね。
ただ、Riverpodを使うにあたって、忘れてはいけないことがあります。
それが``ProviderScope``です。
``ProviderScope``の中で状態を使用しないと、
``Bad state: No ProviderScope found``
のエラーが出てしまいます。
Riverpodを使ったことがある方なら、一度は経験したことがあるのではないでしょうか？

ほとんどの方は、一番最初の``runApp``の後に``ProviderScope``を置いていることでしょう。
中には状態の使用箇所にだけおく、という考え方の人もいるかもしれません。

本記事では``ProviderScope``を置く場所についての検討を行います。
内容は以下の通りです。

- Riverpod の紹介
- 状態初期化の問題点
- 対応策の紹介
- 対応策により発生する事象の紹介
- 解決策

また、結論は以下の通りです。

- ``MaterialApp``より前に``ProviderScope``を置く
- ``Provider``を後から初期化したいならば、それができる位置に``ProviderScope``を**追加する**

##Riverpodとは##
Riverpodとは　Flutterの状態管理手法の一つです。

``StatefulWidget``ではその``Widget``内で状態を管理するため、
他の``Widget``に状態を共有する、というのは難しいです。

これを解決する方法はいくつかあるのですが、
その中の一つがRiverpodとなります。

管理したい状態をグローバルで定義し、
好きな場所でその状態を使用することが可能となります。

基本的な使い方は以下の通りです。

1. 管理したい状態をグローバルで定義する（``Provider``の作成）
2. 使用``Widget``の先祖に``ProviderScope``を配置する
3. 使用したい``Widget``に``ConsumerWidget``を継承させ、``ref.watch(counterProvider)``で状態を取得する


```dart
//管理したい状態をグローバルで定義する（Providerの作成）
final counterProvider = StateProvider((ref) => 0);

//使用Widgetの先祖にProviderScopeを配置する
void main() {
  runApp(
     const ProviderScope(child: MyApp()),
  );
}

//　　・・・

//使用したいWidgetにConsumerWidgetを継承させ、
//ref.watch(counterProvider)で状態を取得する
class Home extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final count = ref.watch(counterProvider);
    return Text('$count');
  }
}

```

##状態初期化の問題点##
さて、この便利なRiverpod、管理したい状態の初期化をグローバルで行なっています。
グローバルで行なっているがために、できないことが存在します。

例えば、
端末の使用言語が日本語なら日本語で、
英語なら英語で初期化する、
といったように、ローカライズした``String``で初期化することです。

ローカライズした``String``の取得には``AppLocalization.of(context)``のように
``context``を使用します。

グローバルの領域では``BuildContext``はまだ作成されていないため、``context``を使っての初期化はできません。

対応策はないのでしょうか？

##対応策##

この対応策は、``context``が使える位置に``ProviderScope``を配置し、
``overrideWithValue``を使って再初期化する、です。


Riverpodには後から``Provider``の値を書き換えることができるように、
``override``の機能が存在します。
``ProviderScope``の``overrides``プロパティの中で、
``Provider.overrideWithValue(初期化したい値)``
とすることで、``Provider``の再初期化ができます。

```dart
final counterProvider = StateProvider((ref) => 0);

void main() {
  runApp(
     const ProviderScope(
       overrides: [
         //counterProviderの値を１００で再初期化
         counterProvider.overrideWithValue(StateController(100)),
       ],
       child: MyApp(),
     ),
  );
}
```
この再初期化は``ProviderScope``の位置に依存します。
``BuildContext``が使える位置に``ProviderScope``を移動させ再初期化を行えば、
``context``を使った値で``Provider``を初期化することが可能となります。

サンプルコードは以下の通りです。
(この例では``context``の使用例として画面サイズ``(MediaQuery.of(context).size)``を使って再初期化を行なっています。)

```dart
final counterProvider = StateProvider((ref) => 0);

void main() {
  runApp(
    const MyApp(),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Builder(builder: (context) {
        //コンテキストを使って値を取得
        int recount = MediaQuery.of(context).size.height.toInt();
        //移動したProviderScopeで再初期化
        return ProviderScope(
          overrides: [
            counterProvider.overrideWithValue(StateController(recount)),
          ],
          child: const PagesNavigator(),
        );
      }),
    );
  }
}

```

この対応で、``ProviderScope``を移動させてしまいました。
これは本当に大丈夫なのでしょうか？

##発生事象　ダイアログでの状態の使用でBad state: No ProviderScope found##
``ProviderScope``を``MaterialApp``より下に置いた場合、
特殊な事例ですが、以下の状況の時に``Bad state: No ProviderScope found``となります。

- ダイアログの構成要素を、自作の``ConsumerWidget``で置き換え、``ref.watch``する

サンプルコードは以下の通りです。

```dart
class Home extends ConsumerWidget {
  const Home({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return Scaffold(
        appBar: AppBar(
          title: const Text("Provider Scope example"),
        ),
        body: Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.spaceEvenly,
            children: [
              ElevatedButton(
                child: const Text("Show Dialog"),
                onPressed: () {
                  showCounterDialog(context);
                }
              ),
            ],
          ),
        ),
    );
  }
}

void showCounterDialog(BuildContext context) {
  showDialog<void>(
    barrierDismissible: false,
    context: context,
    builder: (BuildContext context) => AlertDialog(
      title: const Center(
        child: Text(
          "Dialog",
          style: TextStyle(
            fontSize: 24,
          ),
        ),
      ),
      content: const DialogContent(),
      actions: <Widget>[
        Center(
          child: ElevatedButton(
            onPressed: () {
              Navigator.pop(context);
            },
            child: const Text("back"),
          ),
        )
      ],
    ),
  );
}

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

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return SingleChildScrollView(
      child: Center(
        child: Text("${ref.watch(counterProvider.state).state}"),
      ),
    );
  }
}

```

``AlertDialog``の``content``に、
``DialogContent``という``ConsumerWidget``を継承した``Widget``を配置しています。

普通の書き方のように見えますが、何がいけないのでしょうか。

``showDialog``メソッドの中を見ると、次のようになっています。

```dart
Future<T?> showDialog<T>({
  required BuildContext context,
  required WidgetBuilder builder,
  bool barrierDismissible = true,
  Color? barrierColor = Colors.black54,
  String? barrierLabel,
  bool useSafeArea = true,
  bool useRootNavigator = true,  //useRootNavigatorがtrueになっている
  RouteSettings? routeSettings,
}) {
//省略//

  return Navigator.of(context, rootNavigator: useRootNavigator).push<T>(DialogRoute<T>(
    context: context,
    builder: builder,
    barrierColor: barrierColor,
    barrierDismissible: barrierDismissible,
    barrierLabel: barrierLabel,
    useSafeArea: useSafeArea,
    settings: routeSettings,
    themes: themes,
  ));
}
```

ここで注目すべきは``Navigator.of``の ``useRootNavigator``です。
このパラメータが``true``だと、``context``の中の一番遠い先祖の``Navigator``をとってきます。
今回のアプリだと、``MaterialApp``です。
``MaterialApp``は``ProviderScope``で囲まれていません。
``BuildContext``で参照する位置で``ProviderScope``で囲まれていないが故に、
``Bad state: No ProviderScope found``が出たのだと考えられます。


##解決策##

この解決策は再初期化のための``ProviderScope``と別に、
もう一つ``ProviderScope``を用意し、
``MaterialApp``の前に置くことです。

2つ置いていいの？と思うかもしれませんが、問題ありません。
``ProviderScope``のコードの一部を紹介します。

```dart
class ProviderScope extends StatefulWidget {
//省略//
}

//省略//

class ProviderScopeState extends State<ProviderScope> {
//省略//
  late ProviderContainer container;
//省略//
@override
  void initState() {
    super.initState();
    final scope = context
        .getElementForInheritedWidgetOfExactType<UncontrolledProviderScope>()
        ?.widget as UncontrolledProviderScope?;
//省略//
    container = ProviderContainer(
      parent: scope?.container,
      overrides: widget.overrides,
      observers: widget.observers,
//省略//
    );
  }

//省略//

  @override
  Widget build(BuildContext context) {
//省略//
    return UncontrolledProviderScope(
      container: container,
      child: widget.child,
    );
  }
//省略//
}
```

このコードからわかることは以下の通りです

- ``ProviderScope``は``UncontrollProviderScope``を返すこと
- ``UncontrollProviderScope``は``InheritedWidget``を継承していること
- ``initState``で``getElementForInheritedWidgetOfExactType<UncontrolledProviderScope>()``することにより、
　　　　祖先の``UncontrolledProviderScope``を取得し、``ProviderContainer``を取得していること

``ProviderContainer``は``Provider``の状態を保持しています。

つまり、``ProviderScope``は祖先の``ProviderScope``から``Provider``が管理している状態を取得しているわけです。

これらから``ProviderScope``は複数の``ProviderScope``が存在しても大丈夫なように設計されていることがわかります。

また、公式の例でも``ProviderScope``を2つ重ねて使用している例が紹介されています。

公式の例を引用

```dart
final themeProvider = Provider((ref) => MyTheme.light());

void main() {
  runApp(
    ProviderScope(
      child: MaterialApp(
        // Home uses the default behavior for all providers.
        home: Home(),
        routes: {
          // Overrides themeProvider for the /gallery route only
          '/gallery': (_) => ProviderScope(
            overrides: [
              themeProvider.overrideWithValue(MyTheme.dark()),
            ],
          ),
        },
      ),
    ),
  );
}

```

長くなりましたが、``ProviderScope``の配置について結論としては以下の通りになります。

- ``MaterialApp``より前に``ProviderScope``を置く
- ``Provider``を後から初期化したいならば、それができる位置に``ProviderScope``を**追加する**

##まとめ##
Riverpodの``ProviderScope``をどこにおけばいいのか、について検討しました。

結論は以下の通りです。

- ``MaterialApp``より前に``ProviderScope``を置く
- ``Provider``を後から初期化したいならば、それができる位置に``ProviderScope``を**追加する**


ダイアログを使わないのであれば``MaterialApp``より前に``ProviderScope``を置く必要はないのか、
については検討が必要です。
``ProviderScope``を``MaterialApp``より前に置かなかった場合、
``Provider``が勝手に``dispose``される、という事象が個人開発のアプリ作成の過程でありました。
こちらについては原因がわからず、手元で再現ができませんでした。

現状``ProviderScope``を複数置くデメリットが見当たらないため、
``MaterialApp``より前に配置しておくのが安全かと考えます。

今回のコードは以下のGitHubに公開しています。
``runApp``の後のコードを変更することにより
ダイアログのエラーを確認することが可能です。

https://github.com/Umigishi-Aoi/SetProviderScope

以上、ありがとうございました。

##参考##

https://riverpod.dev/

https://docs.flutter.dev/development/accessibility-and-localization/internationalization

https://pub.dev/documentation/flutter_riverpod/latest/flutter_riverpod/ProviderScope-class.html