🦔

【初心者向け】FlutterでStateProviderを使い状態管理をしてみよう

に公開

こんにちは、ワニかず@40歳 出戻りエンジニアです。
今回は、Flutterで状態管理を行う方法の一つとして存在する、
StateProviderについてまとめました。

はじめに

Flutter アプリケーション開発において、状態管理は非常に重要な要素です。適切な状態管理を行うことで、アプリの保守性、拡張性、パフォーマンスが向上します。今回は、Flutter における状態管理の選択肢の一つである StateProvider について詳しく解説します。

StateProvider とは

StateProvider は、Riverpod パッケージが提供する状態管理ソリューションの一つです。簡単な状態を管理するために設計されており、読み取りと書き込みの両方の操作をサポートします。特に単一の値を管理する場合に最適です。

StateProvider vs Provider

通常の Provider が読み取り専用のデータを提供するのに対し、StateProvider は状態の変更も可能にします。これにより、UI と状態の同期が容易になります。

StateProvider の基本的な使い方

1. パッケージのインストール

まず、pubspec.yaml ファイルに Riverpod パッケージを追加します。

dependencies:
  flutter:
    sdk: flutter
  # Riverpodパッケージをインポート - StateProviderを使用するために必要
  flutter_riverpod: ^2.4.0

コードの説明:

  • flutter_riverpod: ^2.4.0: Riverpodパッケージのバージョン2.4.0以上をインストールします。このパッケージがStateProviderを含む各種プロバイダーを提供します。

2. アプリケーションの準備

アプリケーションのルートで ProviderScope を設定します。

import 'package:flutter/material.dart';
// Riverpodパッケージをインポート
import 'package:flutter_riverpod/flutter_riverpod.dart';

void main() {
  runApp(
    // ★重要★: ProviderScope を追加して Riverpod を有効にする
    // このウィジェットがアプリ全体のProviderの状態を管理する
    ProviderScope(
      child: MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'StateProvider Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: HomePage(),
    );
  }
}

コードの説明:

  • import 'package:flutter_riverpod/flutter_riverpod.dart': Riverpodの機能を使用するために必要なインポート文です。
  • ProviderScope(child: MyApp()): アプリケーションのルートにProviderScopeを配置します。これはStateProviderを含むすべてのプロバイダーの状態を管理・保持するコンテナです。このウィジェットがないとRiverpodのプロバイダーは機能しません。

3. StateProvider の定義

簡単なカウンターアプリを例に、StateProvider を定義します。

import 'package:flutter_riverpod/flutter_riverpod.dart';

// ★重要★: カウンターの値を管理する StateProvider
// グローバル変数として定義することで、アプリのどこからでもアクセス可能
final counterProvider = StateProvider<int>((ref) => 0);

コードの説明:

  • final counterProvider: グローバル変数としてプロバイダーを定義します。これにより、アプリのどこからでもこの状態にアクセスできます。
  • StateProvider<int>: 整数型の値を管理するStateProviderを作成します。ジェネリック型<int>で管理する値の型を指定します。
  • ((ref) => 0): プロバイダーの初期値を0に設定します。refパラメータはProviderReferenceで、他のプロバイダーを参照する際に使用できます。

4. 状態の利用と更新

StateProvider を使って状態を読み取り、更新する方法を見てみましょう。

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

// ★重要★: ConsumerWidgetを継承することで、このウィジェットはプロバイダーの状態を監視できる
class HomePage extends ConsumerWidget {
  
  // ★重要★: WidgetRefパラメータを受け取り、これを通じてプロバイダーにアクセスする
  Widget build(BuildContext context, WidgetRef ref) {
    // ★重要★: StateProvider から状態を取得(値が変更されると自動的に再ビルド)
    final count = ref.watch(counterProvider);
    
    return Scaffold(
      appBar: AppBar(
        title: Text('StateProvider Demo'),
      ),
      body: Center(
        child: Text(
          '$count', // 取得した状態値を表示
          style: TextStyle(fontSize: 40),
        ),
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: [
          FloatingActionButton(
            // ★重要★: StateProviderの状態を更新(インクリメント)
            onPressed: () => ref.read(counterProvider.notifier).state++,
            tooltip: 'Increment',
            child: Icon(Icons.add),
          ),
          SizedBox(height: 10),
          FloatingActionButton(
            // ★重要★: StateProviderの状態を更新(デクリメント)
            onPressed: () => ref.read(counterProvider.notifier).state--,
            tooltip: 'Decrement',
            child: Icon(Icons.remove),
          ),
        ],
      ),
    );
  }
}

コードの説明:

  • class HomePage extends ConsumerWidget: StatelessWidgetではなくConsumerWidgetを継承します。これによりWidgetRefが提供され、プロバイダーにアクセスできるようになります。
  • Widget build(BuildContext context, WidgetRef ref): 通常のbuildメソッドに加え、WidgetRef refパラメータを受け取ります。
  • final count = ref.watch(counterProvider): ref.watchメソッドでStateProviderの現在の値を監視します。値が変更されるとウィジェットが自動的に再ビルドされます。
  • ref.read(counterProvider.notifier).state++:
    • ref.read: 値の変更を監視せず、一度だけ値を読み取ります(ボタン押下時など)
    • .notifier: StateProviderの状態を管理するStateControllerにアクセス
    • .state: 実際の状態値にアクセス
    • ++: 状態値をインクリメント(同様に--はデクリメント)

StateProvider の活用シナリオ

1. フォームの入力状態管理

// ★重要★: テキスト入力の状態を管理するStateProvider
// 文字列型の状態を管理し、初期値は空文字
final textInputProvider = StateProvider<String>((ref) => '');

// 使用例
TextField(
  // ★重要★: テキストが変更されたときにStateProviderの値を更新
  onChanged: (value) => ref.read(textInputProvider.notifier).state = value,
)

コードの説明:

  • final textInputProvider = StateProvider<String>((ref) => ''): 文字列型の状態を管理するStateProviderを作成し、初期値を空文字列に設定します。
  • onChanged: (value) => ref.read(textInputProvider.notifier).state = value:
    • TextField内でテキストが変更されるたびに、StateProviderの状態を新しい値で更新します。
    • ref.readは値の更新時に使用し、UIの再構築を発生させません。
    • .notifier.state = valueで実際の状態値を更新します。

2. トグルスイッチの状態

// ★重要★: ダークモードの設定状態を管理するStateProvider
// 真偽値型の状態を管理し、初期値はfalse(ライトモード)
final isDarkModeProvider = StateProvider<bool>((ref) => false);

// 使用例
Switch(
  // ★重要★: 現在の状態値を監視してスイッチの状態に反映
  value: ref.watch(isDarkModeProvider),
  // ★重要★: スイッチが切り替えられたときに状態を更新
  onChanged: (value) => ref.read(isDarkModeProvider.notifier).state = value,
)

コードの説明:

  • final isDarkModeProvider = StateProvider<bool>((ref) => false): 真偽値型の状態を管理するStateProviderで、初期値はfalse(ライトモード)に設定します。
  • value: ref.watch(isDarkModeProvider):
    • ref.watchでStateProviderの現在の値を監視します。
    • 値が変更されるとSwitchウィジェットが自動的に再構築され、UIに反映されます。
  • onChanged: (value) => ref.read(isDarkModeProvider.notifier).state = value:
    • スイッチが切り替えられたときに状態を更新します。
    • 新しい状態値には引数のvalue(trueまたはfalse)を使用します。

3. フィルター選択の管理

// ★重要★: 選択されたフィルターのインデックスを管理するStateProvider
// 整数型の状態を管理し、初期値は0(すべて)
final selectedFilterProvider = StateProvider<int>((ref) => 0);

// 使用例
SegmentedButton<int>(
  segments: [
    ButtonSegment(value: 0, label: Text('すべて')),
    ButtonSegment(value: 1, label: Text('お気に入り')),
    ButtonSegment(value: 2, label: Text('未完了')),
  ],
  // ★重要★: 現在選択されているフィルターをStateProviderから取得
  selected: {ref.watch(selectedFilterProvider)},
  // ★重要★: 選択が変更されたときにStateProviderを更新
  onSelectionChanged: (Set<int> selection) {
    ref.read(selectedFilterProvider.notifier).state = selection.first;
  },
)

コードの説明:

  • final selectedFilterProvider = StateProvider<int>((ref) => 0): 整数型のStateProviderで、フィルターのインデックスを管理します。初期値は0(「すべて」フィルター)です。
  • selected: {ref.watch(selectedFilterProvider)}:
    • ref.watchでStateProviderの値を監視します。
    • SegmentedButtonウィジェットは選択されたセグメントをSetとして受け取るため、波括弧{}で囲んでSetに変換しています。
    • 状態が変更されると自動的に再ビルドされ、選択状態が更新されます。
  • onSelectionChanged: (Set<int> selection) { ... }:
    • ユーザーが別のセグメントを選択したとき、StateProviderの状態を更新します。
    • selection.firstで選択されたセグメントの値(0, 1, または2)を取得します。

## StateProvider の制限と注意点

1. **単一の値の管理**: StateProvider は単一の値を管理するのに適しています。複雑なオブジェクトや状態の場合は、StateNotifier や Notifier を検討してください。

2. **パフォーマンスへの配慮**: 頻繁に更新される状態の場合、再ビルドの範囲を最小限に抑えるために、ConsumerWidget の代わりに Consumer ウィジェットを使用することを検討してください。

3. **クラスや複雑なオブジェクトの使用**: StateProvider でクラスを使用する場合、イミュータブルなオブジェクトを使用するか、状態の変更時に新しいインスタンスを作成する必要があります。

```dart
// 例:ユーザー情報を管理するStateProvider
final userProvider = StateProvider<User>((ref) => User());

// ★注意★: 良くない例 - オブジェクトの内部プロパティを直接変更
ref.read(userProvider.notifier).state.name = 'John'; // この変更はRiverpodに検出されない

// ★推奨★: 良い例 - 新しいインスタンスを作成して状態を更新
ref.read(userProvider.notifier).state = User(name: 'John'); // Riverpodが変更を検出できる

コードの説明:

  • 良くない例:

    • ref.read(userProvider.notifier).state.name = 'John':
      • Userオブジェクトの内部プロパティを直接変更しています。
      • 重要な問題点: この変更はStateProviderによって検出されません。オブジェクトの参照自体は変わっていないためです。
      • 結果として、UIは再ビルドされず、変更が画面に反映されません。
  • 良い例:

    • ref.read(userProvider.notifier).state = User(name: 'John'):
      • 新しいUserオブジェクトを作成し、stateプロパティを完全に置き換えています。
      • 重要なポイント: この方法では、StateProviderは状態の変更を検出でき、依存するウィジェットを適切に再ビルドします。
      • イミュータブルなアプローチを取ることで、状態管理が予測可能になります。
  • 代替アプローチ:

    • 複雑なオブジェクトを扱う場合は、StateProviderではなくStateNotifierProviderの使用を検討してください。
    • または、.update()メソッドと複製を組み合わせることもできます:
      // 更新メソッドを使った例
      ref.read(userProvider.notifier).update((state) => 
        User(
          id: state.id,       // 既存のIDを保持
          name: 'John',       // 名前を更新
          email: state.email  // 既存のメールを保持
        )
      );
      

Riverpod 2.0 以降の変更点

Riverpod 2.0 からは、.state プロパティを使用する代わりに、.update() メソッドを使用することが推奨されています。これにより、コードの意図がより明確になります。

// 従来の方法(非推奨)
ref.read(counterProvider.notifier).state++;

// ★重要★: 推奨される方法 - updateメソッドを使用
ref.read(counterProvider.notifier).update((state) => state + 1);

コードの説明:

  • ref.read(counterProvider.notifier).state++:

    • 従来の方法では、.stateプロパティに直接アクセスして変更します。
    • この方法は動作しますが、複雑な状態更新では意図が明確でなくなる可能性があります。
  • ref.read(counterProvider.notifier).update((state) => state + 1):

    • .update()メソッドは、現在の状態を引数として受け取るコールバック関数を使用します。
    • コールバック関数は新しい状態値を返し、それが自動的に状態として設定されます。
    • この方法は関数型プログラミングの原則に沿っており、特に複雑な状態更新で意図が明確になります。
    • 複数の更新を連鎖させる場合や条件付き更新に特に有用です。

StateProvider と他の状態管理ソリューションの比較

ソリューション 用途 複雑さ
StateProvider 単一の値の管理
StateNotifierProvider 複雑な状態の管理
NotifierProvider 複雑な状態とビジネスロジック 中〜高
AsyncNotifierProvider 非同期操作を含む状態管理

おしまいに

光回線の「プロバイダー」とか、
「プロバイダー」って色んな意味で使われるから
「プロバイダーって言っても色んな意味があるんだよ」
と、まずは教えてよと、エンジニア1年目に思ったことがあったとさ(遠い目)

Discussion