T-CREATOR

Flutter の状態管理を設計する:Riverpod/Bloc/Provider/Redux の実務指針

Flutter の状態管理を設計する:Riverpod/Bloc/Provider/Redux の実務指針

Flutter アプリケーション開発において、状態管理は避けて通れない重要なテーマです。適切な状態管理の選択は、開発効率やコードの保守性、さらにはアプリのパフォーマンスにまで大きな影響を与えます。

本記事では、Flutter の状態管理ライブラリである Riverpod、Bloc、Provider、Redux について、それぞれの特徴と実務での選択基準を詳しく解説します。初心者の方でも理解できるよう、各ライブラリの基本概念から実装例、選定のポイントまで、段階的にご紹介していきますね。

背景

Flutter における状態管理とは、アプリ内のデータ(状態)を効率的に管理し、UI に反映させる仕組みのことです。シンプルなアプリでは setState だけで十分ですが、規模が大きくなると管理が複雑になります。

状態管理が必要になる理由

Flutter では、Widget ツリー全体で状態を共有したり、画面をまたいでデータを受け渡したりする場面が頻繁に発生します。例えば、ユーザーのログイン情報、ショッピングカートの内容、テーマ設定など、複数の画面から参照・更新されるデータを効率的に管理する必要があるのです。

以下の図で、Flutter アプリにおける状態の流れを確認してみましょう。

mermaidflowchart TB
  user["ユーザー操作"] -->|イベント| ui["UI Widget"]
  ui -->|状態更新要求| state["状態管理層"]
  state -->|データ変更| storage[("データ保存層")]
  storage -->|更新通知| state
  state -->|状態通知| ui
  ui -->|画面更新| display["表示"]
  display -->|フィードバック| user

この図からわかるように、ユーザーの操作が UI を通じて状態管理層に伝わり、データが更新されると UI が自動的に再描画されるという流れが基本となっています。

状態管理ライブラリの役割

状態管理ライブラリは、このデータの流れを整理し、以下の課題を解決してくれます。

  • Widget 間でのデータ共有を容易にする
  • 状態の変更を効率的に UI に反映する
  • テストしやすいコード構造を提供する
  • 大規模なアプリでも保守性を保つ

近年、Flutter コミュニティでは Riverpod、Bloc、Provider、Redux といった複数のライブラリが主流となっています。それぞれに設計思想や使い勝手が異なるため、プロジェクトの特性に応じて適切なものを選択することが重要です。

課題

状態管理ライブラリを選ぶ際、開発者は以下のような課題に直面します。

ライブラリ選定の難しさ

Flutter には数多くの状態管理ライブラリが存在し、それぞれに独自の設計思想やアプローチがあります。初心者にとっては「どれを選べばいいのかわからない」という状況に陥りがちですし、経験者でも「プロジェクトに最適な選択肢は何か」を判断するのは容易ではありません。

主要な選定基準の比較表

以下の表で、4 つの主要ライブラリの特徴を比較してみましょう。

#ライブラリ学習曲線記述量型安全性コミュニティ
1Provider緩やか少ない
2Riverpodやや急少ない
3Bloc多い
4Redux非常に多い

具体的な選定時の悩み

開発現場では、以下のような具体的な悩みが発生します。

学習コストの問題 新しいメンバーがチームに加わった際、複雑な状態管理ライブラリは習得に時間がかかります。特に Bloc や Redux は独自の概念が多く、慣れるまでに時間を要するでしょう。

開発スピードとの兼ね合い プロトタイプや MVP(Minimum Viable Product)を素早く開発したい場合、記述量が多いライブラリは開発速度を低下させる可能性があります。

保守性とスケーラビリティ 小規模なプロジェクトでは Provider で十分でも、将来的に規模が拡大した場合に設計の見直しが必要になることがあります。最初から大規模を見越した設計にするべきか、それともシンプルに始めるべきか、判断が難しいのです。

以下の図で、プロジェクト規模と適切なライブラリの関係を可視化してみましょう。

mermaidflowchart LR
  small["小規模<br/>プロジェクト"] -->|シンプルさ重視| prov["Provider/<br/>Riverpod"]
  medium["中規模<br/>プロジェクト"] -->|バランス重視| riv["Riverpod/<br/>Bloc"]
  large["大規模<br/>プロジェクト"] -->|構造化重視| bloc["Bloc/<br/>Redux"]
  prov -.->|成長| medium
  riv -.->|拡大| large

この図から、プロジェクトの成長に応じて状態管理の選択も見直す必要があることがわかりますね。

型安全性とエラー検出

Flutter は Dart 言語を使用しており、静的型付けの恩恵を受けられます。しかし、状態管理ライブラリによって型安全性の度合いが異なり、開発時のエラー検出能力に差が出ます。Riverpod は compile-time safety を強く意識した設計になっている一方、Provider は runtime でのエラーが発生しやすい場合があるのです。

解決策

各状態管理ライブラリの特徴を理解し、プロジェクトの要件に応じて適切に選択することが解決策となります。ここでは、4 つのライブラリそれぞれの設計思想と実装パターンを詳しく見ていきましょう。

Provider:シンプルさを追求

Provider は Flutter 公式に推奨されていた状態管理ライブラリで、InheritedWidget をラップしてシンプルに使えるようにしたものです。学習コストが低く、小規模から中規模のアプリに適しています。

Provider の基本構造

Provider の基本的な仕組みは、Widget ツリーの上位で状態を提供し、下位の Widget がそれを受け取るという形です。

mermaidflowchart TB
  root["ルート Widget"] -->|provide| provider["Provider"]
  provider -->|状態を保持| state["状態オブジェクト"]
  provider --> child1["子 Widget A"]
  provider --> child2["子 Widget B"]
  child1 -->|watch/read| state
  child2 -->|watch/read| state
  state -.->|変更通知| child1
  state -.->|変更通知| child2

この図のように、Provider は親 Widget から子孫 Widget へ状態を効率的に伝達します。

Provider の実装例:カウンターアプリ

まず、状態を管理するクラスを定義します。ChangeNotifier を継承することで、状態の変更を通知できるようになります。

dartimport 'package:flutter/foundation.dart';

// 状態を管理するクラス
// ChangeNotifierを継承して変更通知機能を持たせる
class CounterModel extends ChangeNotifier {
  int _count = 0;

  // 現在のカウント値を取得
  int get count => _count;

  // カウントを増やすメソッド
  void increment() {
    _count++;
    // 変更を通知してUIを更新
    notifyListeners();
  }
}

次に、Provider を使って状態をアプリ全体で共有できるようにします。

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

void main() {
  runApp(
    // ChangeNotifierProviderでアプリ全体に状態を提供
    ChangeNotifierProvider(
      create: (context) => CounterModel(),
      child: MyApp(),
    ),
  );
}

最後に、UI で状態を使用します。Consumer Widget を使うことで、状態が変更されたときに自動的に再描画されます。

dartclass MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Provider Counter')),
        body: Center(
          // Consumerで状態を監視して表示
          child: Consumer<CounterModel>(
            builder: (context, counter, child) {
              return Text(
                'Count: ${counter.count}',
                style: TextStyle(fontSize: 24),
              );
            },
          ),
        ),
        floatingActionButton: FloatingActionButton(
          // context.read()で状態にアクセスしてメソッド呼び出し
          onPressed: () => context.read<CounterModel>().increment(),
          child: Icon(Icons.add),
        ),
      ),
    );
  }
}

Provider のメリットは、このようにシンプルで理解しやすいコードを書けることです。ただし、アプリが大規模になると、状態の依存関係が複雑になり管理が難しくなる場合があります。

Riverpod:Provider の進化形

Riverpod は Provider の作者が設計した新しいライブラリで、Provider の欠点を改善しています。compile-time safety(コンパイル時の安全性)が高く、BuildContext に依存しない設計が特徴です。

Riverpod の設計思想

Riverpod の最大の特徴は、Provider を Widget ツリーの外で定義できることです。これにより、より柔軟で testable なコードが書けるようになりました。

mermaidflowchart TB
  global["グローバルスコープ"] -->|定義| providers["各種 Provider"]
  providers --> state["StateNotifierProvider"]
  providers --> future["FutureProvider"]
  providers --> stream["StreamProvider"]

  app["アプリ"] -->|ProviderScope| root["ルート Widget"]
  root --> widget["各 Widget"]
  widget -->|ref.watch/read| providers

  providers -.->|状態変更通知| widget

この構造により、Widget ツリーに依存せずに状態管理ができるため、テストやリファクタリングが容易になります。

Riverpod の実装例

まず、Provider を定義します。Riverpod では Provider を global に定義できます。

dartimport 'package:flutter_riverpod/flutter_riverpod.dart';

// StateNotifierで状態を管理
// 状態の変更方法を明示的に定義できる
class CounterNotifier extends StateNotifier<int> {
  CounterNotifier() : super(0); // 初期値は0

  // カウントを増やすメソッド
  void increment() => state++;
}

// グローバルにProviderを定義
// どこからでもアクセス可能
final counterProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
  return CounterNotifier();
});

次に、アプリのルートに ProviderScope を配置します。これが Riverpod を有効化します。

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

void main() {
  runApp(
    // ProviderScopeでRiverpodを有効化
    ProviderScope(
      child: MyApp(),
    ),
  );
}

UI で状態を使用する際は、ConsumerWidget を継承するか、Consumer を使います。

dartclass MyApp extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // ref.watchで状態を監視
    // 状態が変更されると自動的に再ビルド
    final count = ref.watch(counterProvider);

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Riverpod Counter')),
        body: Center(
          child: Text(
            'Count: $count',
            style: TextStyle(fontSize: 24),
          ),
        ),
        floatingActionButton: FloatingActionButton(
          onPressed: () {
            // ref.readでProviderにアクセス
            // notifierを取得してメソッド呼び出し
            ref.read(counterProvider.notifier).increment();
          },
          child: Icon(Icons.add),
        ),
      ),
    );
  }
}

Riverpod は型安全性が高く、コンパイル時にエラーを検出できるため、大規模なアプリでも安心して使えます。また、Provider と違って BuildContext に依存しないため、テストも書きやすいのです。

Bloc:イベント駆動アーキテクチャ

Bloc(Business Logic Component)は、イベントと状態を明確に分離した設計パターンです。UI からイベントを発行し、Bloc がそれを処理して新しい状態を生成するという一方向のデータフローが特徴となっています。

Bloc の基本フロー

Bloc は以下のような明確なデータフローを持ちます。

mermaidflowchart LR
  ui["UI<br/>(Widget)"] -->|イベント発行| bloc["Bloc"]
  bloc -->|イベント処理| logic["ビジネス<br/>ロジック"]
  logic -->|新しい状態生成| state["State"]
  state -->|状態通知| ui

  bloc -.->|副作用| api["外部API/<br/>Repository"]
  api -.->|データ取得| bloc

このように、UI とビジネスロジックが完全に分離されるため、テストやメンテナンスがしやすくなります。

Bloc の実装例

まず、イベントを定義します。ユーザーの操作や外部からの入力を表現します。

dartimport 'package:flutter_bloc/flutter_bloc.dart';

// イベントの基底クラス
// sealed classで全てのイベントタイプを制限
sealed class CounterEvent {}

// カウントを増やすイベント
class IncrementEvent extends CounterEvent {}

// カウントを減らすイベント
class DecrementEvent extends CounterEvent {}

次に、状態を定義します。アプリの現在の状態を表現します。

dart// 状態クラス
// immutableにして予期しない変更を防ぐ
class CounterState {
  final int count;

  const CounterState({required this.count});

  // 状態のコピーを作成するメソッド
  CounterState copyWith({int? count}) {
    return CounterState(count: count ?? this.count);
  }
}

そして、Bloc 本体を実装します。イベントを受け取り、状態を変更するロジックを記述します。

dart// Blocクラス
// イベントを受け取り、状態を変更する
class CounterBloc extends Bloc<CounterEvent, CounterState> {
  CounterBloc() : super(CounterState(count: 0)) {
    // IncrementEventを受け取ったときの処理
    on<IncrementEvent>((event, emit) {
      // 新しい状態を生成して発行
      emit(state.copyWith(count: state.count + 1));
    });

    // DecrementEventを受け取ったときの処理
    on<DecrementEvent>((event, emit) {
      emit(state.copyWith(count: state.count - 1));
    });
  }
}

アプリのルートで BlocProvider を配置し、Bloc のインスタンスを提供します。

dartimport 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

void main() {
  runApp(
    // BlocProviderでBlocを提供
    BlocProvider(
      create: (context) => CounterBloc(),
      child: MyApp(),
    ),
  );
}

最後に、UI で Bloc を使用します。BlocBuilder で状態の変化を監視し、UI を更新します。

dartclass MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Bloc Counter')),
        body: Center(
          // BlocBuilderで状態を監視
          child: BlocBuilder<CounterBloc, CounterState>(
            builder: (context, state) {
              return Text(
                'Count: ${state.count}',
                style: TextStyle(fontSize: 24),
              );
            },
          ),
        ),
        floatingActionButton: Column(
          mainAxisAlignment: MainAxisAlignment.end,
          children: [
            FloatingActionButton(
              // イベントを発行してBlocに処理を依頼
              onPressed: () => context.read<CounterBloc>().add(IncrementEvent()),
              child: Icon(Icons.add),
            ),
            SizedBox(height: 8),
            FloatingActionButton(
              onPressed: () => context.read<CounterBloc>().add(DecrementEvent()),
              child: Icon(Icons.remove),
            ),
          ],
        ),
      ),
    );
  }
}

Bloc は記述量が多くなりますが、その分ロジックが明確に整理され、複雑なビジネスロジックを持つアプリに適しています。

Redux:予測可能な状態管理

Redux は React エコシステムから生まれた状態管理パターンで、単一の Store にアプリケーション全体の状態を保持するのが特徴です。Action、Reducer、Store という 3 つの要素で構成されます。

Redux の構造

Redux は以下のような一方向のデータフローを実現します。

mermaidflowchart TB
  ui["UI<br/>(Widget)"] -->|Action発行| dispatch["Dispatcher"]
  dispatch -->|Actionを渡す| reducer["Reducer"]
  reducer -->|前の状態と<br/>Actionから計算| newstate["新しい状態"]
  newstate -->|状態を更新| store["Store<br/>(単一の状態ツリー)"]
  store -.->|状態変更通知| ui

  middleware["Middleware"] -.->|副作用処理| api["外部API"]
  dispatch -->|横取り| middleware
  middleware -->|処理後| reducer

この図から、Redux が厳格な単一方向フローを実現していることがわかります。

Redux の実装例

まず、Action を定義します。状態の変更を表現する単純なオブジェクトです。

dartimport 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';

// Actionクラス
// 状態変更の意図を表現
class IncrementAction {}
class DecrementAction {}

次に、アプリ全体の状態を表す State を定義します。

dart// アプリ全体の状態を保持
// immutableにして予期しない変更を防ぐ
class AppState {
  final int count;

  const AppState({required this.count});

  // 初期状態を定義
  factory AppState.initial() => AppState(count: 0);
}

そして、Reducer を実装します。Action と現在の状態から新しい状態を計算する純粋関数です。

dart// Reducer関数
// ActionとStateから新しいStateを生成する純粋関数
AppState counterReducer(AppState state, dynamic action) {
  if (action is IncrementAction) {
    // 新しい状態を返す(元の状態は変更しない)
    return AppState(count: state.count + 1);
  } else if (action is DecrementAction) {
    return AppState(count: state.count - 1);
  }

  // マッチしない場合は現在の状態をそのまま返す
  return state;
}

Store を作成し、アプリのルートで提供します。

dartimport 'package:flutter/material.dart';
import 'package:flutter_redux/flutter_redux.dart';
import 'package:redux/redux.dart';

void main() {
  // Storeを作成
  final store = Store<AppState>(
    counterReducer,
    initialState: AppState.initial(),
  );

  runApp(
    // StoreProviderでStoreを提供
    StoreProvider<AppState>(
      store: store,
      child: MyApp(),
    ),
  );
}

最後に、UI で Redux の状態を使用します。StoreConnector で Store に接続します。

dartclass MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: Text('Redux Counter')),
        body: Center(
          // StoreConnectorでStoreの状態を取得
          child: StoreConnector<AppState, int>(
            // Storeから必要なデータを抽出
            converter: (store) => store.state.count,
            builder: (context, count) {
              return Text(
                'Count: $count',
                style: TextStyle(fontSize: 24),
              );
            },
          ),
        ),
        floatingActionButton: StoreConnector<AppState, VoidCallback>(
          // Actionをdispatchするコールバックを作成
          converter: (store) {
            return () => store.dispatch(IncrementAction());
          },
          builder: (context, callback) {
            return FloatingActionButton(
              onPressed: callback,
              child: Icon(Icons.add),
            );
          },
        ),
      ),
    );
  }
}

Redux は最も記述量が多いですが、状態管理が完全に予測可能になり、デバッグやテストが容易になります。大規模で複雑なアプリに適していますね。

4 つのライブラリの比較まとめ

それぞれのライブラリの特徴を整理すると、以下のようになります。

#項目ProviderRiverpodBlocRedux
1学習難易度★☆☆☆☆★★☆☆☆★★★☆☆★★★★☆
2記述量
3ボイラープレート少ない少ない中程度多い
4型安全性
5テスタビリティ
6開発速度速い速い遅い
7大規模対応
8デバッグ機能基本的充実充実充実

この表を参考に、プロジェクトの特性に合わせて選択することをお勧めします。

具体例

実際のプロジェクトでどのライブラリを選ぶべきか、具体的なシナリオを通して見ていきましょう。

シナリオ 1:Todo アプリの開発

Todo アプリのような比較的シンプルなアプリケーションを開発する場合を考えてみます。要件は以下の通りです。

  • Todo の追加、削除、完了状態の切り替え
  • ローカルストレージへの保存
  • シンプルな UI
  • 開発期間:2 週間

このケースでは Provider または Riverpod が適しています。理由は、状態管理の複雑さが高くなく、素早く開発を進められるからです。

Riverpod を使った Todo アプリの実装

Todo アプリを Riverpod で実装する例を見てみましょう。まず、Todo のモデルを定義します。

dartimport 'package:freezed_annotation/freezed_annotation.dart';

// Todoのデータモデル
// freezedを使ってimmutableなクラスを生成
@freezed
class Todo with _$Todo {
  const factory Todo({
    required String id,
    required String title,
    @Default(false) bool isCompleted,
  }) = _Todo;
}

次に、Todo リストを管理する StateNotifier を作成します。

dartimport 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:uuid/uuid.dart';

// TodoリストのStateNotifier
// リストの追加、削除、更新を管理
class TodoListNotifier extends StateNotifier<List<Todo>> {
  TodoListNotifier() : super([]);

  // Todoを追加
  void addTodo(String title) {
    final newTodo = Todo(
      id: Uuid().v4(),
      title: title,
    );
    // 既存のリストに新しいTodoを追加
    state = [...state, newTodo];
  }

  // Todoを削除
  void removeTodo(String id) {
    // 指定されたIDのTodo以外をフィルタリング
    state = state.where((todo) => todo.id != id).toList();
  }

  // 完了状態をトグル
  void toggleTodo(String id) {
    state = [
      for (final todo in state)
        if (todo.id == id)
          // 該当するTodoの完了状態を反転
          todo.copyWith(isCompleted: !todo.isCompleted)
        else
          todo,
    ];
  }
}

Provider を定義します。

dart// TodoリストのProvider
final todoListProvider = StateNotifierProvider<TodoListNotifier, List<Todo>>((ref) {
  return TodoListNotifier();
});

// 未完了のTodoのみをフィルタリングするProvider
final uncompletedTodosProvider = Provider<List<Todo>>((ref) {
  final todos = ref.watch(todoListProvider);
  return todos.where((todo) => !todo.isCompleted).toList();
});

// 完了済みのTodoをカウントするProvider
final completedCountProvider = Provider<int>((ref) {
  final todos = ref.watch(todoListProvider);
  return todos.where((todo) => todo.isCompleted).length;
});

UI で Todo リストを表示し、操作できるようにします。

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

class TodoListScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Todoリストを監視
    final todos = ref.watch(todoListProvider);
    // 完了数を監視
    final completedCount = ref.watch(completedCountProvider);

    return Scaffold(
      appBar: AppBar(
        title: Text('Todo App'),
        // 完了数を表示
        actions: [
          Center(
            child: Padding(
              padding: EdgeInsets.only(right: 16),
              child: Text('完了: $completedCount'),
            ),
          ),
        ],
      ),
      body: ListView.builder(
        itemCount: todos.length,
        itemBuilder: (context, index) {
          final todo = todos[index];
          return TodoItem(todo: todo);
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => _showAddTodoDialog(context, ref),
        child: Icon(Icons.add),
      ),
    );
  }

  // Todo追加ダイアログを表示
  void _showAddTodoDialog(BuildContext context, WidgetRef ref) {
    final controller = TextEditingController();

    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: Text('新しいTodo'),
        content: TextField(
          controller: controller,
          decoration: InputDecoration(hintText: 'タイトルを入力'),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: Text('キャンセル'),
          ),
          TextButton(
            onPressed: () {
              if (controller.text.isNotEmpty) {
                // Todoを追加
                ref.read(todoListProvider.notifier).addTodo(controller.text);
                Navigator.pop(context);
              }
            },
            child: Text('追加'),
          ),
        ],
      ),
    );
  }
}

個別の Todo アイテムを表示するコンポーネントです。

dartclass TodoItem extends ConsumerWidget {
  final Todo todo;

  const TodoItem({required this.todo});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    return ListTile(
      leading: Checkbox(
        value: todo.isCompleted,
        onChanged: (_) {
          // 完了状態をトグル
          ref.read(todoListProvider.notifier).toggleTodo(todo.id);
        },
      ),
      title: Text(
        todo.title,
        // 完了済みは取り消し線
        style: TextStyle(
          decoration: todo.isCompleted
            ? TextDecoration.lineThrough
            : null,
        ),
      ),
      trailing: IconButton(
        icon: Icon(Icons.delete),
        onPressed: () {
          // Todoを削除
          ref.read(todoListProvider.notifier).removeTodo(todo.id);
        },
      ),
    );
  }
}

この実装により、型安全で保守性の高い Todo アプリが完成します。Riverpod の Provider を使うことで、派生した状態(未完了数、完了数など)も簡単に管理できますね。

シナリオ 2:EC サイトの開発

次に、より複雑な EC(電子商取引)サイトの開発を考えてみましょう。要件は以下の通りです。

  • ユーザー認証
  • 商品一覧・詳細表示
  • カート機能
  • 注文履歴
  • 決済処理
  • 複雑な状態管理と API 連携
  • 開発期間:3 ヶ月
  • チーム規模:5 人

このケースでは Bloc が最適です。理由は、複雑なビジネスロジックを整理でき、チーム開発での責任分担が明確になるからです。

Bloc を使った認証機能の実装フロー

EC サイトの認証機能を Bloc で実装する際のフローを図解します。

mermaidsequenceDiagram
  participant User as ユーザー
  participant UI as UI<br/>(LoginScreen)
  participant Bloc as AuthBloc
  participant Repo as AuthRepository
  participant API as 認証API

  User->>UI: ログインボタン押下
  UI->>Bloc: LoginRequestedイベント
  Bloc->>Bloc: 状態を"Loading"に変更
  Bloc->>Repo: ログイン処理依頼
  Repo->>API: POST /auth/login
  API-->>Repo: トークン返却
  Repo-->>Bloc: 認証成功
  Bloc->>Bloc: 状態を"Authenticated"に変更
  Bloc-->>UI: 状態通知
  UI-->>User: ホーム画面へ遷移

この図から、Bloc が UI とビジネスロジックを明確に分離していることがわかります。

認証の状態を定義します。

dartimport 'package:freezed_annotation/freezed_annotation.dart';

// 認証状態
// 複数の状態を型安全に表現
@freezed
class AuthState with _$AuthState {
  // 初期状態
  const factory AuthState.initial() = _Initial;

  // 読み込み中
  const factory AuthState.loading() = _Loading;

  // 認証済み
  const factory AuthState.authenticated({
    required String userId,
    required String token,
  }) = _Authenticated;

  // 未認証
  const factory AuthState.unauthenticated() = _Unauthenticated;

  // エラー
  const factory AuthState.error({
    required String message,
  }) = _Error;
}

認証のイベントを定義します。

dart// 認証イベント
sealed class AuthEvent {}

// ログインリクエスト
class LoginRequested extends AuthEvent {
  final String email;
  final String password;

  LoginRequested({required this.email, required this.password});
}

// ログアウトリクエスト
class LogoutRequested extends AuthEvent {}

// トークンでの自動ログイン
class AutoLoginRequested extends AuthEvent {
  final String token;

  AutoLoginRequested({required this.token});
}

Repository を定義します。実際の API 通信を担当します。

dartimport 'package:http/http.dart' as http;
import 'dart:convert';

// 認証Repository
// APIとの通信を担当
class AuthRepository {
  final String baseUrl;

  AuthRepository({required this.baseUrl});

  // ログイン処理
  Future<LoginResult> login({
    required String email,
    required String password,
  }) async {
    try {
      final response = await http.post(
        Uri.parse('$baseUrl/auth/login'),
        headers: {'Content-Type': 'application/json'},
        body: json.encode({
          'email': email,
          'password': password,
        }),
      );

      if (response.statusCode == 200) {
        final data = json.decode(response.body);
        return LoginResult.success(
          userId: data['userId'],
          token: data['token'],
        );
      } else {
        return LoginResult.failure(
          message: 'ログインに失敗しました',
        );
      }
    } catch (e) {
      return LoginResult.failure(
        message: 'ネットワークエラーが発生しました',
      );
    }
  }

  // ログアウト処理
  Future<void> logout() async {
    // トークンの削除など
    await Future.delayed(Duration(seconds: 1));
  }
}

// ログイン結果
@freezed
class LoginResult with _$LoginResult {
  const factory LoginResult.success({
    required String userId,
    required String token,
  }) = _Success;

  const factory LoginResult.failure({
    required String message,
  }) = _Failure;
}

AuthBloc を実装します。イベントを受け取って状態を変更します。

dartimport 'package:flutter_bloc/flutter_bloc.dart';

// 認証Bloc
// イベントを処理して状態を更新
class AuthBloc extends Bloc<AuthEvent, AuthState> {
  final AuthRepository repository;

  AuthBloc({required this.repository}) : super(AuthState.initial()) {
    // ログインイベントの処理
    on<LoginRequested>(_onLoginRequested);

    // ログアウトイベントの処理
    on<LogoutRequested>(_onLogoutRequested);

    // 自動ログインイベントの処理
    on<AutoLoginRequested>(_onAutoLoginRequested);
  }

  // ログイン処理
  Future<void> _onLoginRequested(
    LoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    // ローディング状態に変更
    emit(AuthState.loading());

    // Repositoryでログイン処理
    final result = await repository.login(
      email: event.email,
      password: event.password,
    );

    // 結果に応じて状態を変更
    result.when(
      success: (userId, token) {
        emit(AuthState.authenticated(userId: userId, token: token));
      },
      failure: (message) {
        emit(AuthState.error(message: message));
      },
    );
  }

  // ログアウト処理
  Future<void> _onLogoutRequested(
    LogoutRequested event,
    Emitter<AuthState> emit,
  ) async {
    await repository.logout();
    emit(AuthState.unauthenticated());
  }

  // 自動ログイン処理
  Future<void> _onAutoLoginRequested(
    AutoLoginRequested event,
    Emitter<AuthState> emit,
  ) async {
    emit(AuthState.loading());

    // トークンの検証などを行う
    await Future.delayed(Duration(seconds: 1));

    emit(AuthState.authenticated(
      userId: 'user123',
      token: event.token,
    ));
  }
}

UI でログイン画面を実装します。

dartimport 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';

class LoginScreen extends StatelessWidget {
  final _emailController = TextEditingController();
  final _passwordController = TextEditingController();

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('ログイン')),
      body: BlocConsumer<AuthBloc, AuthState>(
        // 状態変化時の副作用(画面遷移など)
        listener: (context, state) {
          state.when(
            initial: () {},
            loading: () {},
            authenticated: (userId, token) {
              // 認証成功時はホーム画面へ遷移
              Navigator.pushReplacementNamed(context, '/home');
            },
            unauthenticated: () {},
            error: (message) {
              // エラー時はスナックバーで表示
              ScaffoldMessenger.of(context).showSnackBar(
                SnackBar(content: Text(message)),
              );
            },
          );
        },
        // UIの構築
        builder: (context, state) {
          return Padding(
            padding: EdgeInsets.all(16),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                TextField(
                  controller: _emailController,
                  decoration: InputDecoration(labelText: 'メールアドレス'),
                  keyboardType: TextInputType.emailAddress,
                ),
                SizedBox(height: 16),
                TextField(
                  controller: _passwordController,
                  decoration: InputDecoration(labelText: 'パスワード'),
                  obscureText: true,
                ),
                SizedBox(height: 24),
                // ローディング中はインジケータを表示
                state.maybeWhen(
                  loading: () => CircularProgressIndicator(),
                  orElse: () => ElevatedButton(
                    onPressed: () {
                      // ログインイベントを発行
                      context.read<AuthBloc>().add(
                        LoginRequested(
                          email: _emailController.text,
                          password: _passwordController.text,
                        ),
                      );
                    },
                    child: Text('ログイン'),
                  ),
                ),
              ],
            ),
          );
        },
      ),
    );
  }
}

この実装により、認証の状態が明確に管理され、UI とビジネスロジックが完全に分離されます。テストも書きやすく、複雑な要件にも対応できる設計になっていますね。

シナリオ 3:プロトタイプの素早い開発

スタートアップ企業で、アイデアの検証のためにプロトタイプを素早く開発する場合を考えてみましょう。

  • 開発期間:1 週間
  • 機能:基本的な CRUD 操作
  • チーム:1-2 人
  • 目的:MVP(実用最小限の製品)を素早くリリース

このケースでは Provider が最適です。学習コストが低く、素早く開発を進められるためです。

Provider を使った簡単なメモアプリの実装例を示します。

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

// メモのデータモデル
class Memo {
  final String id;
  String title;
  String content;

  Memo({required this.id, required this.title, required this.content});
}

// メモリストを管理するモデル
class MemoListModel extends ChangeNotifier {
  final List<Memo> _memos = [];

  List<Memo> get memos => _memos;

  // メモを追加
  void addMemo(String title, String content) {
    _memos.add(Memo(
      id: DateTime.now().toString(),
      title: title,
      content: content,
    ));
    notifyListeners();
  }

  // メモを更新
  void updateMemo(String id, String title, String content) {
    final index = _memos.indexWhere((m) => m.id == id);
    if (index != -1) {
      _memos[index].title = title;
      _memos[index].content = content;
      notifyListeners();
    }
  }

  // メモを削除
  void deleteMemo(String id) {
    _memos.removeWhere((m) => m.id == id);
    notifyListeners();
  }
}

このように、Provider を使えば最小限のコードで状態管理を実装でき、開発速度を最大化できます。

選択基準のまとめ

以下の表で、プロジェクトタイプ別の推奨ライブラリをまとめます。

#プロジェクトタイプ推奨ライブラリ理由
1小規模アプリ・プロトタイプProvider学習コストが低く、素早く開発できる
2中規模アプリRiverpod型安全性と柔軟性のバランスが良い
3大規模・複雑なアプリBlocビジネスロジックの整理がしやすい
4予測可能性重視Reduxデバッグやテストが容易
5チーム開発Bloc / Riverpod責任分担が明確、型安全
6個人開発Provider / Riverpod開発速度を優先できる

プロジェクトの要件とチームのスキルセットに応じて、適切なライブラリを選択することが成功の鍵となります。

まとめ

Flutter の状態管理ライブラリには、それぞれ異なる設計思想と適用場面があります。本記事では、Riverpod、Bloc、Provider、Redux という 4 つの主要ライブラリについて、特徴と実装方法を詳しく解説してきました。

Provider は学習コストが低く、小規模なアプリやプロトタイプに最適です。シンプルな API で素早く開発を進められますが、大規模になると管理が難しくなる場合があります。

Riverpod は Provider の進化形で、compile-time safety が高く、BuildContext に依存しない設計が特徴です。中規模から大規模のアプリに適しており、型安全性と開発速度のバランスが取れています。

Bloc はイベント駆動のアーキテクチャで、UI とビジネスロジックを完全に分離できます。複雑なビジネスロジックを持つアプリや、チーム開発に適していますが、記述量が多くなる傾向があります。

Redux は単一の Store で状態を管理し、予測可能なデータフローを実現します。デバッグやテストが容易ですが、最も記述量が多く、学習コストも高めです。

ライブラリの選択は、プロジェクトの規模、チームのスキル、開発期間、将来の拡張性など、様々な要素を考慮して行う必要があります。まずは小さなプロジェクトで各ライブラリを試してみて、チームに合ったものを見つけることをお勧めしますね。

Flutter の状態管理は奥が深く、実際のプロジェクトを通じて理解が深まっていくものです。本記事が、あなたのプロジェクトに最適な状態管理ライブラリを選択する際の指針となれば幸いです。

関連リンク