🔁

【Flutter】redirect を正しく扱うための go_router × Riverpod 設計ガイド

に公開

はじめに

Flutterアプリを開発する際、ユーザーのログイン状態やアプリのメンテナンス状態に応じて画面遷移を制御したい場面はよくあります。
このような場合に便利なのが、go_routerredirect 機能です。

redirect を使えば、現在のアプリの状態(例:ログイン済みかどうか、メンテナンス中かどうか)に応じて表示すべき画面を切り替えることができます。
しかし、この機能は一見シンプルに見えるものの、状態の監視・通知・再構築の流れを正しく理解しないと、うまく機能させるのが難しい側面もあります。

本記事では、この redirectRiverpod と組み合わせて、

  • 明示的に状態を監視し、
  • 状態変化時に GoRouter に通知し、
  • 状況に応じた画面遷移を安全に実現する

という一連の仕組みを、実際の画面構成とルート設計を交えながら丁寧に解説していきます。

記事の対象者

  • Flutterで go_router を使ったことがあるが、redirect の使い方に不安がある方
  • Riverpod を用いた状態管理に慣れてきた方
  • 認証機能やメンテナンス機能を含むアプリを構築したい方
  • 実際に状態に応じてルート構成を柔軟に切り替えたいと考えている方

記事を執筆時点での筆者の環境

[✓] Flutter (Channel stable, 3.29.0, on macOS
    15.3.1 24D70 darwin-arm64, locale ja-JP)
[✓] Android toolchain - develop for Android
    devices (Android SDK version 35.0.0)
[✓] Xcode - develop for iOS and macOS (Xcode 16.1)
[✓] Chrome - develop for the web
[✓] Android Studio (version 2024.2)
[✓] VS Code (version 1.100.0)

サンプルプロジェクト

📝 今回の仕様

  • アプリ起動後の初回画面は基本ログイン画面
  • メンテナンスフラグが立っている場合はどんな状況でもメンテナンス画面にリダイレクトする
  • ログインフラグがオンで、チュートリアル確認フラグがオフだったらチュートリアル画面にリダイレクトする
  • ログインフラグがオン、チュートリアル確認フラグがオンだったらホーム画面にリダイレクトする

ログイン画面 ➡️ チュートリアル画面 ➡️ ホーム画面 ➡️ ログイン画面
チュートリアルをまだ確認していない場合のログイン、その後ログアウト

ログイン画面  ➡️ ホーム画面
すでにチュートリアルを確認している場合のログイン

設定画面  ➡️ メンテナンス画面 ➡️ ホーム画面
擬似的にメンテナスを発動して、5秒後に解除された場合

ログイン画面  ➡️ メンテナンス画面 ➡️ ログイン画面
擬似的にメンテナスを発動して、5秒後に解除された場合

ソースコード

https://github.com/HaruhikoMotokawa/go_router_builder_sample/tree/feature/add_redirect

補足

本記事は、筆者が以前執筆した記事で使用していたプロジェクトをベースに構成しています。
基本的なルートの構築方法や画面遷移に用いる関数の違いについては以下の記事をご覧ください。

https://zenn.dev/harx/articles/d1835a5eb5d0e0

https://zenn.dev/harx/articles/3d3c166a9139ab

redirect の仕組み

実際の実装に入る前に redirect の仕組みについて概要をつかんでいきましょう。

redirect 機能とは?

redirect 機能とは監視している状態の変化に応じて画面のルートを一度破棄して新しいルートを構築し直す機能です。
作り直したルートを再度ルーティングに送り直す、ということです。

例えばアプリのメンテナンス機能を考えてみます。

メンテナンス機能とはサーバーのメンテナンスやアプリの不具合を解消するためなどを理由に、アプリを一時的に使用不可にするための機能です。
この場合、通常のアプリの画面たちとメンテナンス中に表示する画面があったとします。

普段アプリを使う分にはメンテナンス画面には遷移させたくないので、通常画面群のルーティングには組み込まれていない独立した画面がメンテナンス画面です。

アプリをメンテナンス状態にするには、サーバーやFireaseのremote_configなどを使ってアプリに対してメンテナンスフラグを送信します。
そのフラグを監視して、その変化を検知することによって redirect を実行し、メンテナンス画面へ遷移させるのです。

redirect に必要な機構

この機能を動かすには大きく分けて4つの準備が必要です。

  1. redirect される画面とルート
  2. 監視対象の状態とその状態を変える処理
  3. 状態の変更を検知して redirect を実行するように知らせる機能
  4. 状態によって構築すべきルートを出し分ける redirect 機能

redirect される画面とルートの準備

ここからは実際の構築方法を解説していきます。
まず初めに redirect される画面とルートを準備します。

まず、以前の記事で紹介したアプリのルーティングではリダイレクトがありませんでした。
以下の図のように AppRoot ➡️ StatefulShellRoute の一本道ルートでした。

今回の仕様ではそれぞれの画面が独立しているルートのため、以下のような構成になります。

画面の準備

画面は今回新たに追加された3画面を用意します。

ログイン画面

https://github.com/HaruhikoMotokawa/go_router_builder_sample/blob/feature/add_redirect/lib/presentation/screens/login/screen.dart

チュートリアル画面とメンテナンス画面

ルートの準備

上記で準備した画面をもとに各ルートを定義します。

ログインルート

https://github.com/HaruhikoMotokawa/go_router_builder_sample/blob/feature/add_redirect/lib/core/router/route/_route_data/_login_route.dart

チュートリアルルートとメンテナンスルート

各ルートを大元の route.dart に定義します。

ここで注意が必要なのは AppShellRoute の配下に LoginRoute 、 MaintenanceRoute , TutorialRoute を配置している点です。

今回の仕様ではそれぞれのルートは独立しています。例えば NavigationShellRoute のホーム画面からログイン画面に直接遷移できないようにしているのです。

lib/core/router/route/route.dart
// このアノテーションはAppShellRouteにつける必要がある
<AppShellRoute>(
  routes: [
    TypedStatefulShellRoute<NavigationShellRoute>(
      // ...
    ),
    // ここから追加 ---->
    TypedGoRoute<LoginRoute>(
      path: LoginRoute.path,
      name: LoginRoute.name,
    ),
    TypedGoRoute<MaintenanceRoute>(
      path: MaintenanceRoute.path,
      name: MaintenanceRoute.name,
    ),
    TypedGoRoute<TutorialRoute>(
      path: TutorialRoute.path,
      name: TutorialRoute.name,
    ),
    // ここまで <----
  ],
)

/// アプリケーションの大元に位置するシェルルート。
class AppShellRoute extends ShellRouteData { ... }

ここまできたら一度自動生成コマンドを実行すれば画面とルートの準備は完了です。

flutter pub run build_runner build --delete-conflicting-outputs

余談: 設定画面からメンテナンス画面にはいけない

ここまで定義してみた場合に実験で設定画面からメンテナンス画面に遷移を試みてみましょう。
実際にはメンテナンス画面には遷移せず、ホーム画面にリダイレクトされてしまいます。

このことから NavigationShellRoute にいる場合、 AppShellRoute の配下にあるルートには遷移することができないことがわかります。
本来のアプリの挙動で意図しない遷移してしまう危険を防止することができます。

監視対象の状態とその状態を変える処理

今回の仕様でいう必要な状態管理は以下の三つです。

  • ログイン状態
  • チュートリアル確認状態
  • メンテナンス状態

それぞれの状態の取り方は簡易的な実装となっています。
こちらは実際の本番アプリの場合はそれぞれに相応の実装に差し替えていただければと思います。

ログイン処理に伴う状態

https://github.com/HaruhikoMotokawa/go_router_builder_sample/blob/feature/add_redirect/lib/data/repositories/auth/repository.dart

今回はアプリを再起動する度にログアウト状態から始まっています。
本来はサーバーから返ってきたアクセストークンをローカルDBに保持して、その有無によって isLoggedIn に流すような実装になると思われます。

https://github.com/HaruhikoMotokawa/go_router_builder_sample/blob/feature/add_redirect/lib/data/repositories/auth/provider.dart

ログイン状態は isLoggedInProvider として配信します。

チュートリアルの確認状態の処理

https://github.com/HaruhikoMotokawa/go_router_builder_sample/blob/feature/add_redirect/lib/data/repositories/tutorial/repository.dart

今回はアプリ起動時に毎回チュートリアル未確認状態から始まっています。
本来であれば shared_preferences などのローカルDBにチュートリアルを確認したかどうかの値を保存し、毎回保存した値を取り出して isTutorialChecked に流す実装になると思われます。

https://github.com/HaruhikoMotokawa/go_router_builder_sample/blob/feature/add_redirect/lib/data/repositories/tutorial/provider.dart

チュートリアルの確認状態は isTutorialCheckedProvider として配信します。

メンテナンス状態

https://github.com/HaruhikoMotokawa/go_router_builder_sample/blob/feature/add_redirect/lib/data/repositories/maintenance/repository.dart

setMaintenanceMode を実行すると5秒後に自動でメンテナンスを解除するようにしています。

本来であればここはFirebaseのremote_configなどで流れてきた値をもとに isMaintenanceMode として値を流すような実装になると思われます。

https://github.com/HaruhikoMotokawa/go_router_builder_sample/blob/feature/add_redirect/lib/data/repositories/maintenance/provider.dart

メンテナンス状態は isMaintenanceModeProvider として配信します。

状態の変更を検知して redirect を実行するように知らせる機能

ここからが主要な機能構築になっています。

ログイン状態、チュートリアル確認状態、メンテナンス状態を監視して、変更を検知したらしたら GoRouter に知らせる機能を作ります。
go_router パッケージ内ではこの機能を有したオブジェクトである ListenablerefreshListenable という引数に渡す必要があります。

この Listenableriverpod で扱うにはそれぞれの知識が必要で複雑なので順番に解説していきます。

Listenable とは?

Listenable は「通知を送ることができるオブジェクト」を定義するための抽象クラス(インターフェース)です。

abstract class Listenable { ... }

📌 なにができるの?
• リスナー(listener)を登録 できる
• 変更があったときにリスナーに通知 できる

例えば、ボタンを押したら何かが変わる → 画面を更新したい、というような場面で使われます。

🌱 Listenable には2つの派生系がある
1. ValueListenable
• 「通知 + 値を持つ」タイプ。
• 今の値(current value) を取得できる。
• 例: ValueNotifier<String>('Hello')
2. Animation
• ValueListenable をさらに拡張して、「アニメーションの方向(前進 or 戻る)」の概念を追加。
• アニメーションに特化した通知ができる。

今回の refreshListenable では ValueListenable が関係してきます。

abstract class ValueListenable<T> extends Listenable { ... }

🔧 よく使う実装クラス
⭐️ ChangeNotifier
• Listenable を実装する代表的なクラス。
• notifyListeners() で登録されたすべてのリスナーに通知。
• Provider や setState による状態管理の基盤にもなる。
⭐️ ValueNotifier<T>
• ValueListenable を実装。
• value を変えると、自動で通知される。
• 状態管理がとても簡単にできるので、軽量なアプリでよく使われる。

今回の refreshListenable では ValueNotifier が関係してきます。

class ValueNotifier<T> extends ChangeNotifier implements ValueListenable<T> { ... }

riverpodValueNotifier を返却する provider を実装するには?

上記の流れを汲んでいくと、 ValueNotifier で変更を流せばいいんだなということがわかりました。
これを riverpod で実装するには一工夫が必要です。ただ単に ValueNotifier<T> を返す provider ではうまくいきません。
この件に関しては riverpod の公式ドキュメントにも対応策が記載されています。

riverpod の提供する Raw 型を使って Raw<ValueNotifier<T>> を返却するようにします。

https://riverpod.dev/ja/docs/essentials/websockets_sync#listenable-オブジェクトに関する考慮事項

🔍 Raw<T> とは?

Raw<T> は 「Riverpod に対して、この型は特別な扱いをしなくていいよ」 という意図を示すための 型エイリアス(typedef)です。

typedef Raw<T> = T;

→ つまり、実行時には何の影響もない(Tそのもの)けれど、コード生成時やLinterの警告を避ける目的で使うものです。

🔧 主な使い道

① AsyncValue に自動変換されたくないとき

通常、非同期処理を返すとRiverpodが AsyncValue<T> にラップします。


Future<int> myProvider(...) async => 42;

// ⇒ ref.watch(myProvider) の戻り値は AsyncValue<int>

ただし、ラップされたくないときは Raw<Future<int>> を使います。


Raw<Future<int>> myProvider(...) async => 42;

// ⇒ ref.watch(myProvider) は Future<int> のまま!

② Linterに「この型は意図的に使っている」と伝える

例えば ChangeNotifier を返すと、通常はLinterに警告されます。


MyNotifier myProvider(...) => MyNotifier(); // ❌ 警告

そんなときは以下のように書くことで警告を回避できます:


Raw<MyNotifier> myProvider(...) => MyNotifier(); // ✅ OK

refreshListenableProvider を実装する

ここまでの解説を踏まえて、各種の状態を監視して変更を検知した際にその変更を通知するためのprovider を以下のように定義します。

lib/core/router/redirect/refresh_listenable.dart
(keepAlive: true)
Raw<ValueNotifier<int>> refreshListenable(Ref ref) {
  final notifier = ValueNotifier<int>(0);

  void notify() {
    // 安全なタイミングまでちょっと待つ
    Future.microtask(() {
      // ValueNotifierの値を更新して通知をトリガー
      notifier.value++;
    });
  }

  // 各状態が変わったときに value を更新して通知をトリガー
  final maintenanceInfoSub =
      ref.listen(isMaintenanceModeProvider, (_, __) => notify());

  final isLoggedInSub = ref.listen(isLoggedInProvider, (_, __) => notify());

  final isTutorialCheckedSub =
      ref.listen(isTutorialCheckedProvider, (_, __) => notify());

  // 万が一、このProviderが破棄された時のクリーンアップ処理
  ref.onDispose(() {
    notifier.dispose();
    isLoggedInSub.close();
    maintenanceInfoSub.close();
    isTutorialCheckedSub.close();
  });

  // ValueNotifierが変更されたときにref.notifyListeners()で依存Providerに通知
  notifier.addListener(() {
    ref.notifyListeners();

    if (kDebugMode) {
      print('⭐️⭐️⭐️ notifier value ${notifier.value} ⭐️⭐️⭐️');
    }
  });

  return notifier;
}

👀 状態を監視と通知
ref.listen で各種状態を監視しています。
その値に何らかの変更があった場合は notify() を実行して変更を通知しています。

🛠️ 通知は少し待機
notify() では単純に notifier.value++; としているだけですが、 Future.microtask で少し待機させないと変更通知がうまく伝播されないことがあるので入れています。
Future.microtask を使う理由は、ref.listen による変更検知の直後は Riverpod 側の内部状態がまだ安定していない場合があるためです。
すぐに .value++ を実行すると通知が正しく伝播されないことがあります。microtask によって1フレーム先送りすることでこれを回避できます。

🗑️ 廃棄時の安全処理
この provider は基本的にアプリが起動されている間は破棄されてほしくはないので keepAlive: true としています。
万が一破棄された場合に備えて、ref.listen で作成されたサブスクリプションを明示的に close() する処理も追加しています。

📲 通知を送信する
最後の方で入れている ref.notifyListeners(); で変更を通知しています。この通知先はつまりは GoRouter インスタンスを生成している provider となります。

状態によって構築すべきルートを出し分ける redirect 機能

最後に変更を検知したらどのルートを出し分けるかのを決めて返却する機能を定義します。
こちらは provider を使って依存性注入できるようにした RedirectController クラスに定義していきます。

lib/core/router/redirect/controller.dart
(keepAlive: true)
RedirectController redirectController(Ref ref) => RedirectController(ref);

class RedirectController {
  RedirectController(this.ref);

  final Ref ref;
}

redirect を行う関数の全体像

GoRouter の引数に入れる redirect 関数は以下のような定義となっています。

/// The signature of the redirect callback.
typedef GoRouterRedirect = FutureOr<String?> Function(
    BuildContext context, GoRouterState state);

それを踏まえた RedirectController で定義する関数は以下のとおりです。
クラス名により実行処理は自明なので、命名は call としました。
引数において今回はBuildContext context は使わないので GoRouterState state のみとしました。

そして、この関数は最終的に String? を返さなければいけません。

返却すべき値はリダイレクトすべきルートのパスです。

/// リダイレクト処理を行う
FutureOr<String?> call(GoRouterState state) async {
  // 現在のパスを取得
  final fullPath = state.fullPath;

  // メンテナンスモードの状態を取得
  final isMaintenanceMode = await ref.read(isMaintenanceModeProvider.future);

  // メンテナンスモードの場合はメンテナンス画面へリダイレクト
  if (isMaintenanceMode) return _maintenanceGuard(fullPath);

  // ログイン状態を取得
  final isLoggedIn = await ref.read(isLoggedInProvider.future);

  // ログインしているかどうかでリダイレクト先を決定
  if (isLoggedIn) return _authGuard(fullPath);

  // ログインしていない場合のリダイレクト処理
  return _noAuthGuard(fullPath);
}

大きな流れは以下のとおりです。

① 現在のパスを取得
GoRouterState にはリダイレクト前のアプリが現在表示している画面のルートがパスとして保持されています。
その現在地を使って各状態によってリダイレクトする画面(ルート)をどこにするのかを決定していきます。

② メンテナンス状態の取得とハンドリング
isMaintenanceModeProvider の状態を取得して、もしもメンテナンス中であれば _maintenanceGuard 内でリダイレクト先を返します。

③ ログイン状態の取得とハンドリング
isLoggedInProvider の状態を取得して、もしもログイン中であれば _authGuard 内でリダイレクト先を返します。

④ ログインしていない場合のハンドリング
メンテナンス中でもなく、ログイン中でもない場合は _noAuthGuard 内でリダイレクト先を返します。

リダイレクトの動き _maintenanceGuard

ここでは _maintenanceGuard の動きを見ていきましょう。

/// メンテナンスモードの場合のリダイレクト処理
String? _maintenanceGuard(String? fullPath) {
  // メンテナンスモード中は全ての画面をメンテナンス画面にリダイレクト
  if (fullPath != const MaintenanceRoute().location) {
    return const MaintenanceRoute().location;
  }

  return null;
}

この関数内に入ってきた時点ですでにメンテナンス状態であることは確定しています。
やっていることは現在のパスがメンテナンス画面のルートかどうかを判定し、まだメンテナンス画面にいないのであれば MaintenanceRoute().location; を返します。

それで終わりかというとそうではありません。
ここで MaintenanceRoute().location; をリターンした後に実はもう一度 call 関数が呼び出されます。

GoRouterreidirect 関数は、null が返されるまで反復処理します。

なので、一度目のリダイレクトが終わってもう一度実行された場合に再び call が呼ばれ、メンテナンス中かどうかを判定し、
_maintenanceGuard まで到達します。

そこで今度は fullPath != const MaintenanceRoute().locationfalse となっているので、 return null; が返ります。
ここでようやく、リダイレクト処理は終了します。


その他のリダイレクト関数も基本的な作りは _maintenanceGuard と同じです。

その他のプライベート関数
/// ログインしている場合のリダイレクト処理
Future<String?> _authGuard(String? fullPath) async {
  final isTutorialChecked = await ref.read(isTutorialCheckedProvider.future);

  // チュートリアルが未完了の場合はチュートリアル画面へリダイレクト;
  if (!isTutorialChecked) return _tutorialGuard(fullPath);

  // チュートリアルが完了している場合はホーム画面へリダイレクト
  if (fullPath == const LoginRoute().location ||
      fullPath == const MaintenanceRoute().location ||
      fullPath == const TutorialRoute().location) {
    return const HomeRoute().location;
  }

  return null;
}

/// チュートリアルが完了していない場合のリダイレクト処理
String? _tutorialGuard(String? fullPath) {
  // チュートリアルが未完了の場合はチュートリアル画面へリダイレクト
  if (fullPath != const TutorialRoute().location) {
    return const TutorialRoute().location;
  }
  return null;
}

/// ログインしていない場合のリダイレクト処理
String? _noAuthGuard(String? fullPath) {
  if (fullPath != const LoginRoute().location) {
    return const LoginRoute().location;
  }
  return null;
}

GoRouterrefreshListenableredirect を設定する

最後に今までに定義してきた 状態の変更を検知して redirect を実行するように知らせる機能 である refreshListenableProvider状態によって構築すべきルートを出し分ける redirect 機能 を設定すれば完了です。

lib/core/router/app_router/app_router.dart
(keepAlive: true)
GoRouter appRouter(Ref ref) {
  return GoRouter(
    // ...
    refreshListenable: ref.read(refreshListenableProvider), // 💡💡💡
    redirect: (_, state) => ref.read(redirectControllerProvider).call(state), // 💡💡💡
    // ...
  );
}

終わりに

本記事では、go_routerredirect 機能と Riverpod を組み合わせて、アプリの状態に応じた柔軟な画面遷移を実現する方法を詳しく解説しました。

特に以下のポイントを中心に構成しました:

  • redirect の動作原理とその再評価のタイミング
  • 状態変更を通知するための refreshListenable の仕組み
  • Riverpodで Listenable を扱うための Raw<ValueNotifier> の活用
  • アプリの設計意図をルート設計にどう落とし込むか

単なるテクニックにとどまらず、「なぜこの設計にするのか」を意識して実装していくことで、より保守性・拡張性の高いアプリを構築できるはずです。

この記事が、redirect の仕組みを正しく理解し、実用的に導入していくための一助となれば幸いです。

Discussion