広告 Flutter Dart アプリ開発

Flutter Riverpod 3.x とgo_router

Flutter 3.xが安定版になってきたので、全面的に書き直し。今回はgo_routerとの共存も含めたソースにしてます。

Flutter Riverpodの要点

用語説明と概要

一般的にはstate.dartというファイルに状態を維持するための変数を記載し、それを他のページでインポートして使う感じ。
ver.3ではジェネレータを使って自動で適切なProvider,Notifierを使うようにすることが前提(後述)

ConsumerWidget, ConsumerStatefulWidget: 外部の「状態(State)」を観察することが出来るWidget。主に内部変数を持たないシンプルなものと、内部変数を持つもの(Stateful)を使い分ける(他にも色々ある)
ref経由でProviderの中にあるStateを覗くことができる。(詳細は後述)

State:状態。グローバル変数のようなもの。単純(int,String等)、複雑(Listやクラス等)、Future(単純・複雑)、Stream(単純・複雑)等の種類がある。

Provider : 変数等の「状態(State)」をラップしているもの。

Notifier:「状態(State)」を変更するときに必須の機能。

main()関数では、MyApp()関数をProviderScope()関数で囲む必要がある。

特に注意すべき点

  • レガシー表記が原則としてエラーとなります。(一応逃げ道はあるけど)
  • ref.watch() またはref.listen()していないと、autoDispose()自動破棄がデフォルト。(ref.Readは、常に必要とされているとはみなされない)
    「@riverpod」と記載するところを「@Riverpod(keepAlive: true)」とすると破棄されない。
    (僕はこれにハマりました。ref.read()ばかり使ってたので。)
  • ref.watch(): 画面を「書き直す(リビルド)」ために使う。(常に最新の状態を表示し続けたいとき)
  • ref.listen(): 何か「アクションを起こす(副作用)」ために使う。(値が変わった瞬間だけ、1回処理を走らせたいとき)
  • go_routerで、context.go(/) とすると、初期化されてしまうことがある。
    go_routerで文字列を渡す場合、push,popなら大丈夫。context.go('/')を使いたいときは、extra で値を渡すのが安全。

Flutter 3.x のサンプルソース

いきなりですが、完全動作するサンプルソースを提示します。2026年2月時点のパッケージバージョンで、動作確認済みです。

このサンプルの仕様

ホームページ、画面A、画面Bがあります。
ホームページのドロワーメニューから画面Aを起動し、例えば画面Aで撮影し、画面Bで画像処理するように、画面AB間を行き来できるようにする。(今回は、画面Aは完全なダミーで、画面Bで文字列を入力するようになってます。)
画面Bでデータが確定したら、ホーム画面でデータを取得し表示します。画面ABは閉じられます。

モジュール追加のコツ

モジュールの依存関係は、とても複雑です。特にRiverpod絡みは日々変わります。SDKもガンガンバージョンアップします。
したがって、モジュールを1つ1つ登録するのではなく、一気に登録した方が、最新のモジュールで、かつ依存関係も完璧になります。

flutter pub add flutter_riverpod riverpod_annotation go_router build_runner riverpod_generator

ただ、このままだと全てアプリに含まれてしまうので、build_runnerとriverpod_generatorは、手作業で「dev_dependencies」の下に移しましょう!(ここがかなり重要です!)

サンプル全ソース(String型を共有するだけのシンプルな構成)

重要な部分を赤色にしています。なお、モジュールのバージョンは、2026年2月の依存関係が成り立つ最新版です。
最新版のモジュールを使いたい場合は、上記方法でモジュール追加してください。

pubspec.yaml

name: test_riverpod3
description: "A new Flutter project."
publish_to: 'none' # Remove this line if you wish to publish to pub.dev

version: 1.0.0+1

environment:
  sdk: '>=3.10.8 <4.0.0'

dependencies:
  flutter:
    sdk: flutter
  flutter_riverpod: ^3.1.0
  riverpod_annotation: ^4.0.0
  go_router: ^17.1.0

  # iOS スタイルのアイコンには CupertinoIcons クラスで使用します。
  cupertino_icons: ^1.0.8

dev_dependencies:
  flutter_test:
    sdk: flutter
  build_runner: ^2.11.0
  riverpod_generator: ^4.0.0+1

 # 以下の「flutter_lints」パッケージには、優れたコーディングプラクティスを促進するための推奨lintセットが含まれています。
 #lint とは、コードを実行せずに解析して、バグになりやすい書き方 や スタイルの問題 を警告する仕組みのことです。
 # このパッケージが提供するlintセットは、パッケージのルートにある `analysis_options.yaml` ファイルで有効化されます。
 # 特定のlintルールを無効化したり、追加のlintルールを有効化したりする方法については、このファイルを参照してください。
  flutter_lints: ^6.0.0

flutter:
  # 次の行は、Material Icons フォントがアプリケーションに含まれ、Material Icons クラスのアイコンが使用できるようにします。
  uses-material-design: true

  # assetsの記載例です。
  # assets:
  #   - images/a_dot_burr.jpeg
  #   - images/a_dot_ham.jpeg

  # アプリケーションにカスタムフォントを追加するには、
  # この「flutter」セクションにフォントセクションを追加してください。
  # このリストの各エントリには、フォントファミリー名を含む「family」キーと、
  # フォントのアセットやその他の記述子を含むリストを含む「fonts」キーが必要です。
  # 例:
  # fonts:
  #   - family: Schyler
  #     fonts:
  #       - asset: fonts/Schyler-Regular.ttf
  #       - asset: fonts/Schyler-Italic.ttf
  #         style: italic
  #   - family: Trajan Pro
  #     fonts:
  #       - asset: fonts/TrajanPro.ttf
  #       - asset: fonts/TrajanPro_Bold.ttf
  #         weight: 700
  #

main.dart

// lib/main.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';

// 各ページファイルをインポート
import 'home_page.dart';
import 'page_a.dart';
import 'page_b.dart';

void main() {
  // ProviderScopeでアプリ全体をラップする
  runApp(const ProviderScope(child: MyApp()));
}

// GoRouterの定義
final _router = GoRouter(
  initialLocation: '/',
  routes: [
    GoRoute(
      path: '/',
      builder: (context, state) => const HomePage(),
    ),
    GoRoute(
      path: '/a',
      builder: (context, state) => const PageA(),
    ),
    GoRoute(
      path: '/b',
      builder: (context, state) => const PageB(),
    ),
  ],
);

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp.router(
      routerConfig: _router,
      title: 'Riverpod Split File Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
        useMaterial3: true,
      ),
    );
  }
}

shared_state.dart

// lib/shared_state.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';

// ファイル名と同じ名前で .g.dart を指定します(要build_runner)
part 'shared_state.g.dart';

@riverpod
class SharedString extends _$SharedString {
  @override
  String? build() {
    // 初期値は null (未設定)
    return null;
  }

  // 値を更新するメソッド
  void update(String newValue) {
    state = newValue;
  }
}

home_page.dart (この例では内部変数のないシンプルなConsumerWidgetになってます。)

// lib/home_page.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'shared_state.dart'; // 状態ファイルをインポート

class HomePage extends ConsumerWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // 状態を監視。変更があればこのWidgetだけ再描画される
    final sharedValue = ref.watch(sharedStringProvider);

    return Scaffold(
      appBar: AppBar(
        title: const Text('Home Page'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      // ドロワーメニュー
      drawer: Drawer(
        child: ListView(
          padding: EdgeInsets.zero,
          children: [
            const DrawerHeader(
              decoration: BoxDecoration(color: Colors.indigo),
              child: Text(
                'Menu',
                style: TextStyle(color: Colors.white, fontSize: 24),
              ),
            ),
            ListTile(
              leading: const Icon(Icons.arrow_forward),
              title: const Text('ページAへ移動'),
              onTap: () {
                // ドロワーを閉じてから移動
                Navigator.pop(context); 
                context.push('/a');
              },
            ),
          ],
        ),
      ),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Text(
              'ページBから受け取ったデータ:',
              style: TextStyle(fontSize: 16),
            ),
            const SizedBox(height: 10),
            Text(
              sharedValue ?? '(データなし)',
              style: const TextStyle(
                fontSize: 28, 
                fontWeight: FontWeight.bold, 
                color: Colors.indigo
              ),
            ),
          ],
        ),
      ),
    );
  }
}

ConsumerStatefullWidgetでは、以下の点に注意してください。
・buildに「WidgetRef ref」の記載が不要
・StateオブジェクトはConsumerStateオブジェクトになる。

page_a.dart

// lib/page_a.dart
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';

class PageA extends StatelessWidget {
  const PageA({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page A')),
      body: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            const Icon(Icons.looks_one, size: 80, color: Colors.grey),
            const SizedBox(height: 20),
            const Text('ここはページAです'),
            const SizedBox(height: 20),
            ElevatedButton.icon(
              onPressed: () {
                // スタックの上にページBを積む
                context.push('/b');
              },
              icon: const Icon(Icons.arrow_forward),
              label: const Text('ページBへ進む'),
            ),
          ],
        ),
      ),
    );
  }
}

page_b.dart

// lib/page_b.dart
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'shared_state.dart'; // 状態ファイルをインポート

class PageB extends ConsumerStatefulWidget {
  const PageB({super.key});

  @override
  ConsumerState<PageB> createState() => _PageBState();
}

class _PageBState extends ConsumerState<PageB> {
  // 入力コントローラー
  final _controller = TextEditingController();

  @override
  void dispose() {
    _controller.dispose(); // メモリリーク防止のため破棄
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Page B')),
      body: Padding(
        padding: const EdgeInsets.all(24.0),
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            const Text(
              'Homeへ送るデータを入力してください',
              textAlign: TextAlign.center,
              style: TextStyle(fontSize: 18),
            ),
            const SizedBox(height: 20),
            TextField(
              controller: _controller,
              decoration: const InputDecoration(
                labelText: 'メッセージ',
                border: OutlineInputBorder(),
                prefixIcon: Icon(Icons.edit),
              ),
            ),
            const SizedBox(height: 30),
            FilledButton(
              onPressed: () {
                final text = _controller.text;
                if (text.isEmpty) return;

                // 1. Riverpodの状態を更新 (notifierをreadしてメソッドを呼ぶ)
                ref.read(sharedStringProvider.notifier).update(text);

                // 2. Homeに戻る
                // context.go('/') は、これまでの履歴(A, B)を破棄して
                // ルート('/')を新規に表示します。
                context.go('/');
              },
              child: const Padding(
                padding: EdgeInsets.all(12.0),
                child: Text('保存してHomeに戻る', style: TextStyle(fontSize: 16)),
              ),
            ),
          ],
        ),
      ),
    );
  }
}

ビルド用のbashスクリプト

flutter には様々なビルド手順が必要なので、次のようなビルド用スクリプトを準備したほうがよい。(Ubuntuの場合)

run_build.sh

#!/bin/bash

# ==========================================
# Flutter Build Assistant
# ==========================================

# 色の定義
GREEN='\033[0;32m'
BLUE='\033[0;34m'
YELLOW='\033[1;33m'
RED='\033[0;31m'
NC='\033[0m' # No Color

# ------------------------------------------
# 関数定義
# ------------------------------------------

# 通常ビルドの処理
run_normal_build() {
    echo -e "${YELLOW}[Normal] Cleaning project...${NC}"
    flutter clean

    echo -e "${YELLOW}[Normal] Getting packages...${NC}"
    flutter pub get

    # build_runner が pubspec.yaml にあるか確認
    if grep -q "build_runner:" pubspec.yaml; then
        echo -e "${GREEN}[Normal] build_runner detected. Running build...${NC}"
        dart run build_runner build --delete-conflicting-outputs
    else
        echo -e "${BLUE}[Normal] build_runner not found. Skipping.${NC}"
    fi

    # flutter_launcher_icons が pubspec.yaml にあるか確認
    if grep -q "flutter_launcher_icons:" pubspec.yaml; then
        echo -e "${GREEN}[Normal] flutter_launcher_icons detected. Generating icons...${NC}"
        dart run flutter_launcher_icons
    else
        echo -e "${BLUE}[Normal] flutter_launcher_icons not found. Skipping.${NC}"
    fi
}

# 拡張ビルドの処理
run_extended_build() {
    echo -e "${RED}[Extended] Upgrading Flutter SDK...${NC}"
    flutter upgrade

    echo -e "${RED}[Extended] Clearing global package cache...${NC}"
    flutter pub cache clean -f

    echo -e "${RED}[Extended] Upgrading project packages...${NC}"
    flutter pub upgrade

    # 最後に通常ビルドを実行
    run_normal_build
}

# ------------------------------------------
# メインメニュー
# ------------------------------------------

echo -e "${BLUE}=== Flutter Build Menu ===${NC}"
echo "1. 通常ビルド (Clean, Get, Build, Icons)"
echo "2. 拡張ビルド (Upgrade SDK/Modules, Cache Clear + Normal)"
echo "3. Quit"
echo -e "${BLUE}==========================${NC}"

read -p "番号を入力してください (1-3): " option

case $option in
    1)
        echo -e "${GREEN}>>> Starting Normal Build <<<${NC}"
        run_normal_build
        echo -e "${GREEN}>>> Normal Build Completed <<<${NC}"
        ;;
    2)
        echo -e "${RED}>>> Starting Extended Build <<<${NC}"
        echo "※ SDKのアップグレードやキャッシュクリアには時間がかかります"
        run_extended_build
        echo -e "${GREEN}>>> Extended Build Completed <<<${NC}"
        ;;
    3)
        echo "終了します。"
        exit 0
        ;;
    *)
        echo -e "${RED}無効な選択です。終了します。${NC}"
        exit 1
        ;;
esac

Riverpod3.xで、シンプルな配列を扱う

Flutter Riverpod 3.1.0(および riverpod_generator)環境において、配列(List)を扱う際の鉄則具体的な実装パターンを解説します。

バージョン3系でも、最も重要なルールは 「不変性(Immutability)」 です。

Riverpodは state の中身(メモリ上の参照先)が新しいものに置き換わらない限り、変更を検知しません。

つまり、state.add(item)禁止です。必ず state = [...state, item] のように新しい配列を作って代入します。


基本的な実装例(文字列のリスト)

まずはシンプルな List<String> を扱う例です。

string_list.dart

import 'package:riverpod_annotation/riverpod_annotation.dart';

part 'string_list.g.dart'; // ファイル名.g.dartになる

@riverpod
class StringList extends _$StringList {
  @override
  List<String> build() {
    return []; // 初期値(空の配列)
  }

  // 【追加】スプレッド演算子(...)を使って新しい配列を作成
  void add(String item) {
    state = [...state, item];
  }

  // 【削除】条件に合う要素を除外した新しい配列を作成
  void remove(String target) {
    state = [
      for (final item in state)
        if (item != target) item,
    ];
    // または: state = state.where((i) => i != target).toList();
  }

  // 【更新】特定のインデックスを書き換える
  void updateAt(int index, String newItem) {
    if (index < 0 || index >= state.length) return;
    
    // 配列のコピーを作成
    final newState = [...state];
    // コピーを書き換え
    newState[index] = newItem;
    // 新しい配列をセット
    state = newState;
  }
}

Riverpod3.xで、クラスを扱う

Riverpod 3系でクラスを扱う場合、不変性(Immutability)と copyWith(値の更新用メソッド)が必須となるため、freezed パッケージとの併用がデファクトスタンダード(事実上の標準)です。

下記モジュールが必要です。なお、モジュールの追加は、flutter pub addで一気に追加し、次に開発時のみ必要なものをdev_dependencies以下に移しましょう。

pubspec.yaml (2026年2月時点での例)

environment:
  # Riverpod 3系は新しいDartの機能を使うため、最新のSDK指定を推奨
  sdk: '>=3.5.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  # --- Riverpod 3.x 本体 ---
  flutter_riverpod: ^3.1.0
  # コード生成用のアノテーション (@riverpod)
  riverpod_annotation: ^3.1.0

  # --- クラス生成用 (Freezed) ---
  # データクラスを不変にし、copyWithや==演算子を自動生成する
  freezed_annotation: ^2.4.4
  
  # --- JSON対応 (API通信をするなら必要) ---
  json_annotation: ^4.9.0

dev_dependencies:
  flutter_test:
    sdk: flutter

  # --- ビルドランナー (コード生成の実行役) ---
  build_runner: ^2.4.13

  # --- Riverpodジェネレーター ---
  # Riverpodのアノテーションを見てコードを生成
  riverpod_generator: ^3.1.0

  # --- Freezedジェネレーター ---
  # Freezedのアノテーションを見てデータクラスを生成
  freezed: ^2.5.7
  
  # --- JSONジェネレーター ---
  json_serializable: ^6.9.0

実装サンプル

Step 1: データクラスの定義 (user.dart)

@freezed を使うことで、面倒な copyWith メソッドを手動で書く必要がなくなります。

user.dart

import 'package:freezed_annotation/freezed_annotation.dart';

part 'user.freezed.dart';
part 'user.g.dart'; // json_serializableを使う場合

@freezed
class User with _$User {
  // 不変クラスとして定義
  const factory User({
    required String id,
    required String name,
    @Default(0) int age,
  }) = _User;

  factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}

Step 2: Riverpodでの利用 (user_notifier.dart)

Riverpod 3 の Generator 構文と組み合わせることで、非常に簡潔に記述できます。

user_notifier.dart

import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'user.dart';

part 'user_notifier.g.dart';

@riverpod
class UserNotifier extends _$UserNotifier {
  @override
  User build() {
    return const User(id: '1', name: 'Guest');
  }

  void updateName(String newName) {
    // Freezedのおかげで .copyWith が使える
    // これにより「不変性を保ったままの一部更新」が可能になる
    state = state.copyWith(name: newName);
  }
}

run_build.shの実行を忘れずに!

-Flutter Dart アプリ開発