🔧

Flutter: `setState() or markNeedsBuild() called during build`エラーの解決ガイド

に公開

🧭 go_routerのTabControllerとStatefulNavigationShellの正しい同期方法

📖 はじめに

Flutterでgo_routerStatefulNavigationShellTabControllerを同期させる際、setState() or markNeedsBuild() called during buildというエラーに遭遇することがあります。

このエラーは、Flutterのビルドサイクル中に予期せぬUIの再構築が要求されたときに発生します。

この記事では、実際に発生した問題の根本原因と、2つの解決策を解説します。

🎯 問題のシナリオ

アプリには、StatefulNavigationShellを使った3つのタブ画面(ライブラリ、コース、レッスン)があります。
コースタブ(タブ2)で特定のレッスンをタップすると、そのレッスンタブ(タブ3)に遷移するという、ごく一般的な要件です。

class _MainShellState extends State<MainShell> with SingleTickerProviderStateMixin {
  late final TabController _tabController;

  
  void initState() {
    super.initState();
    _tabController = TabController(
      length: 3,
      vsync: this,
      initialIndex: widget.navigationShell.currentIndex,
    );

    // TabControllerの変更をNavigationShellに同期
    _tabController.addListener(_onTabChanged);
  }

  // ❌ 問題のあるリスナー実装
  void _onTabChanged() {
    if (!_tabController.indexIsChanging) {
      // go_routerでナビゲート
      widget.navigationShell.goBranch(_tabController.index);
    }
  }

  
  void didUpdateWidget(MainShell oldWidget) {
    super.didUpdateWidget(oldWidget);
    // NavigationShellの変更をTabControllerに同期
    if (widget.navigationShell.currentIndex != _tabController.index) {
      _tabController.index = widget.navigationShell.currentIndex;
    }
  }
}

⚠️ エラーの根本原因

🤔 indexIsChangingの誤解

多くの開発者は、TabController.indexIsChangingプロパティを使えば、不要なナビゲーション呼び出しを防げると考えます。

しかし、indexIsChangingはユーザーがタブをドラッグしている間だけtrueになります

プログラマティックに_tabController.indexを変更した場合(例:didUpdateWidget()内での同期)、indexIsChangingfalseのままです。

エラーが発生するシーケンス

実際のエラーメッセージ

setState() or markNeedsBuild() called during build.
This Router<Object> widget cannot be marked as needing to build
because the framework is already in the process of building widgets.

✅ 解決策1:リスナーを削除する(推奨)

基本原則

TabController.addListenerは不要。TabBar.onTapで直接ナビゲーションを処理する。

実装

class _MainShellState extends State<MainShell> with SingleTickerProviderStateMixin {
  late final TabController _tabController;

  
  void initState() {
    super.initState();
    _tabController = TabController(
      length: 3,
      vsync: this,
      initialIndex: widget.navigationShell.currentIndex,
    );
    // ✅ リスナーは不要!
  }

  
  void didUpdateWidget(MainShell oldWidget) {
    super.didUpdateWidget(oldWidget);
    // NavigationShellの変更をTabControllerに同期(一方向のみ)
    if (widget.navigationShell.currentIndex != _tabController.index) {
      _tabController.index = widget.navigationShell.currentIndex;
    }
  }

  
  Widget build(BuildContext context) {
    return Watch((context) {
      final areTabsDisabled = _libraryViewModel.isCourseListEmpty.value;

      return Scaffold(
        body: SafeArea(
          child: Column(
            children: [
              TabBar(
                controller: _tabController,
                onTap: (index) {
                  // ✅ ユーザー操作を直接ハンドル
                  if (areTabsDisabled && (index == 1 || index == 2)) {
                    _tabController.index = _tabController.previousIndex;
                    return;
                  }
                  widget.navigationShell.goBranch(index);
                },
                tabs: [...],
              ),
              Expanded(child: widget.navigationShell),
            ],
          ),
        ),
      );
    });
  }
}

なぜこのアプローチが最良なのか

データフローがシンプルかつ明確:

ユーザータップ
  ↓
TabBar.onTap → goBranch() → navigationShell更新
  ↓
didUpdateWidget() → _tabController.index更新
(終了)

リスナーありの場合の不要な処理:

ユーザータップ
  ↓
TabBar.onTap → goBranch() → navigationShell更新
  ↓
didUpdateWidget() → _tabController.index更新
  ↓
_onTabChanged()発火 → ガード条件でスキップ(何もしない)

このアプローチの利点

  1. シンプル: リスナーとガード条件が不要
  2. 意図が明確: ユーザー操作とナビゲーションの関係が直接的
  3. 構造的に安全: エラーの「着火装置」そのものを取り除く
  4. メンテナンス性: コードが少なく、理解しやすい

🔄 解決策2:リスナーにガード条件を追加する(代替案)

何らかの理由でリスナーが必要な場合、ガード条件で二重ナビゲーションを防ぐことができます。

実装

void _onTabChanged() {
  // ✅ go_routerの状態とTabControllerのインデックスが異なる場合のみナビゲーション実行
  if (widget.navigationShell.currentIndex != _tabController.index) {
    final areTabsDisabled = _libraryViewModel.isCourseListEmpty.value;

    // タブ無効時の処理
    if (areTabsDisabled && (_tabController.index == 1 || _tabController.index == 2)) {
      _tabController.index = widget.navigationShell.currentIndex;
      return;
    }

    // go_routerでナビゲート
    widget.navigationShell.goBranch(_tabController.index);
  }
}

動作シーケンス

このアプローチの欠点

  • リスナーが実際には何もしない(ガードでスキップされる)
  • 不要なコードが残る
  • 理解に時間がかかる

📊 比較:なぜindexIsChangingでは不十分なのか

条件 プログラマティック変更 ユーザードラッグ 二重ナビゲーションを防げるか
!indexIsChanging false → 実行 true → スキップ ❌ 防げない(プログラマティック変更時に実行される)
navigationShell.currentIndex != _tabController.index false → スキップ true → 実行 ✅ 防げる(既に同期されている場合スキップ)
リスナー削除 - - ✅ 防げる(着火装置そのものを削除)

💡 まとめ

🔍 エラーの根本原因

  • TabController.indexIsChangingは、ユーザーのドラッグ操作のみを検出する
  • プログラマティックな変更(didUpdateWidget()での同期)ではfalseのまま
  • そのため、ビルド中に二重ナビゲーション呼び出しが発生する

🎖️ 推奨される解決策

リスナーを削除し、TabBar.onTapで直接ナビゲーションを処理する

// ✅ 推奨: シンプルで明確
TabBar(
  controller: _tabController,
  onTap: (index) {
    widget.navigationShell.goBranch(index);
  },
)

この実装により:

  • ユーザー操作とナビゲーションの関係が直接的で理解しやすい
  • リスナーとガード条件という間接的な仕組みが不要
  • エラーの可能性を構造的に排除
  • コードがシンプルでメンテナンスしやすい

📝 重要な教訓

  1. indexIsChangingは「ユーザーがドラッグ中か」を示すプロパティであり、「プログラマティックな変更か」を検出するものではない

  2. TabControllerのリスナーは、TabBarとgo_routerを組み合わせる場合、通常は不要

    • TabBar.onTap: ユーザー操作を直接ハンドル(TabController → NavigationShell)
    • didUpdateWidget(): NavigationShellの変更をTabControllerに反映(NavigationShell → TabController)
    • この双方向同期で十分機能する
  3. 「着火装置」を取り除くアプローチが最もクリーン

    • エラーを防ぐガードを追加するより、エラーの原因そのものを削除する
    • シンプルな実装は、バグを減らし、理解を容易にする

Discussion