⚙️

StateNotifierとStateNotifierProviderを用いた副作用制御とテスト容易性の向上

に公開

ここから記事本文

はじめに

本記事はChatGPTを用いて生成されました。

Flutter開発において、状態管理はアプリケーションの品質や拡張性を大きく左右する重要なテーマです。特に、マップや位置情報を多用するモバイルサービスでは、非同期処理や副作用が頻発し、それらを適切に制御することが求められます。伝統的なStateProviderChangeNotifierによる管理はシンプルですが、副作用の管理やテストの面で課題が残ることも多いです。

そこで、RiverpodのStateNotifierStateNotifierProviderを活用した副作用制御の手法が注目されています。これらはロジックの分離を促進し、副作用を明示的に管理しやすくすることで、コードの保守性とテスト容易性を大幅に向上させることが可能です。本記事では、これらの技術を深掘りし、実践的な実装例と応用パターンを交えながら、モバイルの位置情報系アプリに役立つ具体的な活用法を解説します。


背景・基礎知識

RiverpodとStateNotifierの位置づけ

  • Riverpodは、Flutterの状態管理ライブラリで、依存性注入と状態管理を安全に行うことができる。
  • StateNotifierは状態を管理するクラスで、状態の変更通知を明示的に制御できる。
  • StateNotifierProviderStateNotifierを外部から利用可能にし、UIなどのリスナーへ状態を提供する。

従来のStateProviderは単純な状態の読み書きに向いているが、副作用の制御には向かない。StateNotifierは状態変更をメソッドにまとめることで、ロジックの分離とテスト容易性を向上させる。

用語定義

用語 説明
StateNotifier 状態の変更ロジックを持つクラス
StateNotifierProvider StateNotifierをリスニング・提供するProvider
副作用(Side Effect) 状態変更以外の非同期処理や外部とのやりとり(API呼び出しなど)
テスト容易性 ロジック単位でユニットテストが行いやすい設計

図解イメージ

+--------------------+
| UI (ConsumerWidget) |
+---------+----------+
          |
          v
+--------------------+         +--------------------+
| StateNotifierProvider|------->| StateNotifier      |
+--------------------+         | (状態管理+副作用) |
                               +--------------------+

本論:StateNotifierとStateNotifierProviderによる副作用制御の仕組み

1. StateNotifierで副作用を管理

StateNotifierは状態を不変オブジェクトとして保持し、状態変更のトリガーとなるメソッド内で副作用を制御します。例えば、API通信や位置情報取得などの非同期処理をここで実装し、処理結果を状態に反映します。

class LocationState {
  final double lat;
  final double lng;
  final bool loading;
  final String? error;

  LocationState({required this.lat, required this.lng, this.loading = false, this.error});
  
  LocationState copyWith({double? lat, double? lng, bool? loading, String? error}) {
    return LocationState(
      lat: lat ?? this.lat,
      lng: lng ?? this.lng,
      loading: loading ?? this.loading,
      error: error,
    );
  }
}

class LocationNotifier extends StateNotifier<LocationState> {
  LocationNotifier() : super(LocationState(lat: 0, lng: 0));

  Future<void> fetchCurrentLocation() async {
    try {
      state = state.copyWith(loading: true, error: null);
      // 位置情報取得の副作用処理(例)
      final position = await Geolocator.getCurrentPosition();
      state = state.copyWith(lat: position.latitude, lng: position.longitude, loading: false);
    } catch (e) {
      state = state.copyWith(error: e.toString(), loading: false);
    }
  }
}

2. StateNotifierProviderで状態の外部提供

StateNotifierProviderLocationNotifierをUIに提供し、UIは状態の変化に応じて再描画されます。副作用はLocationNotifier内部に閉じているため、UI層はシンプルになります。

final locationProvider = StateNotifierProvider<LocationNotifier, LocationState>(
  (ref) => LocationNotifier(),
);

3. テスト容易性の向上

副作用をLocationNotifierに集約することで、副作用をモックしやすく、ユニットテストを容易に実施できます。

void main() {
  test('fetchCurrentLocation updates state with position', () async {
    final notifier = LocationNotifier();

    await notifier.fetchCurrentLocation();

    expect(notifier.state.loading, false);
    expect(notifier.state.error, null);
    expect(notifier.state.lat, isNot(0));
    expect(notifier.state.lng, isNot(0));
  });
}

具体例・コード例

下記は位置情報を取得し、マップ表示に反映する簡易アプリの実装例です。

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

class LocationState {
  final double lat;
  final double lng;
  final bool loading;
  final String? error;

  LocationState({required this.lat, required this.lng, this.loading = false, this.error});

  LocationState copyWith({double? lat, double? lng, bool? loading, String? error}) {
    return LocationState(
      lat: lat ?? this.lat,
      lng: lng ?? this.lng,
      loading: loading ?? this.loading,
      error: error,
    );
  }
}

class LocationNotifier extends StateNotifier<LocationState> {
  LocationNotifier() : super(LocationState(lat: 0, lng: 0));

  Future<void> fetchCurrentLocation() async {
    try {
      state = state.copyWith(loading: true, error: null);
      final position = await Geolocator.getCurrentPosition();
      state = state.copyWith(lat: position.latitude, lng: position.longitude, loading: false);
    } catch (e) {
      state = state.copyWith(error: e.toString(), loading: false);
    }
  }
}

final locationProvider = StateNotifierProvider<LocationNotifier, LocationState>(
  (ref) => LocationNotifier(),
);

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

class MyApp extends ConsumerWidget {
  
  Widget build(BuildContext context, WidgetRef ref) {
    final locationState = ref.watch(locationProvider);
    final locationNotifier = ref.read(locationProvider.notifier);

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('位置情報取得サンプル')),
        body: Center(
          child: locationState.loading
              ? CircularProgressIndicator()
              : locationState.error != null
                  ? Text('エラー: ${locationState.error}')
                  : Column(
                      mainAxisAlignment: MainAxisAlignment.center,
                      children: [
                        Text('緯度: ${locationState.lat}'),
                        Text('経度: ${locationState.lng}'),
                        SizedBox(height: 20),
                        ElevatedButton(
                          onPressed: () => locationNotifier.fetchCurrentLocation(),
                          child: Text('現在位置を取得'),
                        ),
                      ],
                    ),
        ),
      ),
    );
  }
}

手順

  1. LocationStateで位置情報と読み込み状態を保持
  2. LocationNotifierで非同期の位置情報取得処理を実装
  3. locationProviderでNotifierを公開
  4. UIで状態を監視し、副作用はNotifier内に閉じる設計

応用・発展

1. 複数副作用の連結

StateNotifier内で複数のAPI呼び出しや位置情報取得を連鎖的に行うことで、複雑なフローも一元管理可能。

Future<void> fetchLocationAndWeather() async {
  try {
    state = state.copyWith(loading: true, error: null);
    final position = await Geolocator.getCurrentPosition();
    state = state.copyWith(lat: position.latitude, lng: position.longitude);
    final weather = await fetchWeather(position.latitude, position.longitude);
    // 天気情報を別の状態に反映など

Discussion