はじめに (対象読者・この記事でわかること)

この記事は、Flutterで画面遷移時に前の画面から受け取った変数を setState() で更新しようとしてうまくいかないと悩んでいる中級者以上の開発者を対象としています。
読み進めることで、以下ができるようになります。

  • 変数が「参照渡し」か「コピー渡し」かを正しく理解する
  • setState() が UI に反映されない典型的なパターンを把握する
  • ValueNotifierProviderStatefulWidget の正しい組み合わせで状態を管理できる

本記事は、実務で頻繁に遭遇する「画面間での状態共有」問題を具体例と共に解決することを目的に執筆しました。

前提知識

この記事を読み進める上で、以下の知識があるとスムーズです。

  • Flutter と Dart の基本的な文法・ウィジェット構造
  • StatefulWidgetStatelessWidget の違い
  • 基本的な画面遷移(Navigator.push / pop

setState() だけでは解決できないケースの概要

setState() はそのウィジェットの State オブジェクト が保持しているローカル変数を更新したときに UI を再描画させる仕組みです。
しかし、前の画面から渡された変数が 別の State オブジェクト に保持されている場合、setState() を呼び出しても参照元が変わらないことがあります。

典型的なコード例

Dart
// 前画面 (ScreenA) class ScreenA extends StatelessWidget { final int counter = 0; @override Widget build(BuildContext context) { return ElevatedButton( child: Text('Go to B'), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (_) => ScreenB(initialValue: counter), ), ); }, ); } } // 後画面 (ScreenB) class ScreenB extends StatefulWidget { final int initialValue; const ScreenB({required this.initialValue, Key? key}) : super(key: key); @override _ScreenBState createState() => _ScreenBState(); } class _ScreenBState extends State<ScreenB> { late int value; @override void initState() { super.initState(); value = widget.initialValue; // 受け取った値をローカルにコピー } void _increment() { value++; // ローカル変数は更新 setState(() {}); // UI 再描画 } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Screen B')), body: Center(child: Text('Value: $value')), floatingActionButton: FloatingActionButton( onPressed: _increment, child: Icon(Icons.add), ), ); } }

上記では、ScreenAcounterScreenB に渡していますが、ScreenB がローカルにコピーしただけなので ScreenA 側の counter は変化しません。ScreenBsetState()value のみを再描画対象とし、ScreenA の状態には何ら影響を与えないため「前画面の変数が変わらない」現象が起きます。

正しい状態共有の手順と実装例

ここからは、状態を共有すべき対象どのタイミングで UI を再描画すべきか を整理し、実装パターンを三つ紹介します。

1. ValueNotifierValueListenableBuilder を使う

ValueNotifier<T> は変更があったときにリスナーへ通知できるシンプルな仕組みです。画面間で同一インスタンスを共有すれば、どちらの画面でも value が変わった瞬間に UI が自動的に更新されます。

実装手順

  1. 共有したい変数を ValueNotifier<int> として定義(例えばシングルトンや Provider 経由で提供)
  2. 受け取る画面は ValueListenableBuilder でラップし、変更時に UI を再構築
  3. increment() などの操作は notifier.value++ で行うだけ

コード例

Dart
// shared_counter.dart import 'package:flutter/material.dart'; class CounterNotifier extends ValueNotifier<int> { CounterNotifier(int value) : super(value); } // main.dart(グローバルに提供) final CounterNotifier globalCounter = CounterNotifier(0); void main() { runApp( MaterialApp( home: ScreenA(), ), ); } // ScreenA class ScreenA extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Screen A')), body: Center( child: ValueListenableBuilder<int>( valueListenable: globalCounter, builder: (_, value, __) => Text('Counter: $value'), ), ), floatingActionButton: FloatingActionButton( onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => ScreenB()), ), child: Icon(Icons.navigate_next), ), ); } } // ScreenB class ScreenB extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Screen B')), body: Center( child: ValueListenableBuilder<int>( valueListenable: globalCounter, builder: (_, value, __) => Text('Counter: $value'), ), ), floatingActionButton: FloatingActionButton( onPressed: () => globalCounter.value++, child: Icon(Icons.add), ), ); } }

この構成では、ScreenAScreenB が同一の globalCounter インスタンスを参照しているため、どちらで値が変わっても他方の UI が自動的に更新されます。

2. Provider パッケージで状態管理

provider は公式が推奨する軽量 DI/状態管理ライブラリです。ChangeNotifier と組み合わせると、notifyListeners() で全参照ウィジェットを再描画させられます。

実装手順

  1. ChangeNotifier を継承した CounterModel クラスを作成
  2. MultiProvider(または ChangeNotifierProvider)でアプリ全体に注入
  3. Consumer<CounterModel> または context.watch<CounterModel>() で UI にバインド

コード例

Dart
// counter_model.dart import 'package:flutter/foundation.dart'; class CounterModel extends ChangeNotifier { int _count = 0; int get count => _count; void increment() { _count++; notifyListeners(); } } // main.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'counter_model.dart'; import 'screen_a.dart'; import 'screen_b.dart'; void main() { runApp( ChangeNotifierProvider( create: (_) => CounterModel(), child: MaterialApp(home: ScreenA()), ), ); } // screen_a.dart class ScreenA extends StatelessWidget { @override Widget build(BuildContext context) { final counter = context.watch<CounterModel>().count; return Scaffold( appBar: AppBar(title: Text('Screen A')), body: Center(child: Text('Counter: $counter')), floatingActionButton: FloatingActionButton( onPressed: () => Navigator.push( context, MaterialPageRoute(builder: (_) => ScreenB()), ), child: Icon(Icons.navigate_next), ), ); } } // screen_b.dart class ScreenB extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Screen B')), body: Center( child: Consumer<CounterModel>( builder: (_, model, __) => Text('Counter: ${model.count}'), ), ), floatingActionButton: FloatingActionButton( onPressed: () => context.read<CounterModel>().increment(), child: Icon(Icons.add), ), ); } }

Provider の利点は、非同期処理やテスト が容易になる点です。setState() で UI を手動で更新する必要がなく、モデル側のロジックだけで状態が伝搬します。

3. StatefulWidget 同士で直接参照を渡す(注意点あり)

状態をシンプルに保ちたい場合、画面遷移時に State オブジェクト自体を渡す 手法もあります。これは InheritedWidget の手作り版と考えても良いですが、ウィジェットツリーが深くなるほど管理が煩雑 になるので、上記のパターンに比べて推奨度は低いです。

実装イメージ

Dart
class ScreenA extends StatefulWidget { @override _ScreenAState createState() => _ScreenAState(); } class _ScreenAState extends State<ScreenA> { int counter = 0; void _increment() { setState(() => counter++); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Screen A')), body: Center(child: Text('Counter: $counter')), floatingActionButton: FloatingActionButton( onPressed: () => Navigator.push( context, MaterialPageRoute( builder: (_) => ScreenB(parentState: this), ), ), child: Icon(Icons.navigate_next), ), ); } } // ScreenB class ScreenB extends StatelessWidget { final _ScreenAState parentState; const ScreenB({required this.parentState, Key? key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text('Screen B')), body: Center(child: Text('Parent Counter: ${parentState.counter}')), floatingActionButton: FloatingActionButton( onPressed: parentState._increment, child: Icon(Icons.add), ), ); } }

問題点
- parentStatedispose されるタイミングの管理が難しい
- アプリ規模が大きくなると依存関係が絡み合い、保守性が低下

そのため、実務では ValueNotifierProvider の利用が主流です。

ハマった点やエラー解決

発生した問題 原因例 解決策
setState() が呼ばれても画面が更新されない 変更対象が別ウィジェットのローカル変数 変更対象を UI が直接参照している State 内の変数にするか、ValueNotifier/Provider で共有
Navigator.pop() 後に再描画が走らない 前画面の State が破棄されたまま参照している pop 前に setState で前画面の State を更新するか、上記共有メカニズムに切り替える
Providernull になる Provider がウィジェットツリーの上位に配置されていない runApp 直下または MaterialApp の上位に ChangeNotifierProvider を配置
ValueNotifier が意図しないタイミングでリセットされる ValueNotifierStatelessWidget のビルドメソッド内で生成 final で外部(トップレベル、Provider など)に保持し、ウィジェットごとに再生成しない

まとめ

本記事では、画面間で受け渡した変数が setState() だけでは更新できない 典型的なケースと、ValueNotifierProvider、直接参照 の三つの状態共有パターンを比較・実装例付きで紹介しました。

  • setState()同一ウィジェットのローカル状態 のみを再描画する
  • 状態を画面間で共有したい場合は ValueNotifierProvider がシンプルかつ拡張性が高い
  • 直接 State オブジェクトを渡す手法は小規模プロトタイプ向きで、規模拡大時はリファクタリングが必須

これらの手法を正しく使うことで、Flutter アプリの状態管理が安定し、デバッグや保守が格段に楽になります。次回は、RiverpodBloc といった高度な状態管理フレームワークの導入例を取り上げる予定です。

参考資料