Flutter: `setState() or markNeedsBuild() called during build`エラーの解決ガイド
🧭 go_routerのTabControllerとStatefulNavigationShellの正しい同期方法
📖 はじめに
Flutterでgo_routerのStatefulNavigationShellとTabControllerを同期させる際、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()内での同期)、indexIsChangingはfalseのままです。
エラーが発生するシーケンス
実際のエラーメッセージ
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()発火 → ガード条件でスキップ(何もしない)
このアプローチの利点
- シンプル: リスナーとガード条件が不要
- 意図が明確: ユーザー操作とナビゲーションの関係が直接的
- 構造的に安全: エラーの「着火装置」そのものを取り除く
- メンテナンス性: コードが少なく、理解しやすい
🔄 解決策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);
},
)
この実装により:
- ユーザー操作とナビゲーションの関係が直接的で理解しやすい
- リスナーとガード条件という間接的な仕組みが不要
- エラーの可能性を構造的に排除
- コードがシンプルでメンテナンスしやすい
📝 重要な教訓
-
indexIsChangingは「ユーザーがドラッグ中か」を示すプロパティであり、「プログラマティックな変更か」を検出するものではない -
TabControllerのリスナーは、TabBarとgo_routerを組み合わせる場合、通常は不要
-
TabBar.onTap: ユーザー操作を直接ハンドル(TabController → NavigationShell) -
didUpdateWidget(): NavigationShellの変更をTabControllerに反映(NavigationShell → TabController) - この双方向同期で十分機能する
-
-
「着火装置」を取り除くアプローチが最もクリーン
- エラーを防ぐガードを追加するより、エラーの原因そのものを削除する
- シンプルな実装は、バグを減らし、理解を容易にする
Discussion