Flutter入門の壁はウィジェットツリー。Claude CodeでDart状態管理まで突破する
Flutterのウィジェットツリーと状態管理(Riverpod/Provider)でつまずく人へ。Claude Codeで迷子にならず、iOS/Android同時開発まで進める書き方を実例で。
「Flutter、ボタンを置くまでは10分で行けたんですよ」
知り合いのWebエンジニアがそう言って、画面を見せてくれました。たしかにボタンは出ている。でも、押しても数字が増えない。彼はonPressedの中で値を+1していたのに、画面はうんともすんとも言わないんです。
僕も最初にハマったのは、ここでした。Flutterは「値を変えれば画面が変わる」じゃなくて、「値を変えたと宣言したら、画面を作り直す」世界。この一歩目を踏み外すと、ウィジェットツリーという言葉が急に呪文に見えてきます。
この記事は、その呪文を解くところから始めます。ウィジェットツリーの考え方、setStateからRiverpodまでの状態管理の選び方、iOSとAndroidを同時に進めるときの落とし穴、そしてClaude CodeにDartを書かせるときに僕が必ず渡している指示まで、手を動かしながらまとめます。
この記事の要点
- Flutterの画面は「部品の入れ子(ウィジェットツリー)」。状態を持つ場所を、ツリーのどこに置くかで設計が決まる。
- 状態管理は規模で選ぶ。小さい画面は
setState/ChangeNotifier、アプリ全体で共有するならRiverpodやProvider。最初から重い道具を入れない。 - iOS/Androidは同じDartコードでも、権限・署名・
Info.plist/AndroidManifest.xmlは別物。ここはClaude Codeに「差分を先に見せて」と頼む。 - Claude Codeには「
buildの中で状態を作らない」「変更はcontrollerに閉じる」「flutter analyzeとflutter testまで実行」を毎回明記すると事故が減る。 - 下のカート画面のコードは依存追加ゼロでそのまま動く。まずこれを動かしてからRiverpodに置き換えるのがいちばん速い。
ウィジェットツリーは「マトリョーシカ」だと思うと早い
Flutterの画面は、部品(Widget)を入れ子にして組み立てます。Scaffoldの中にAppBarとListView、その中にListTile、さらにその中にTextやIconButton。これがウィジェットツリーです。
僕はこれをマトリョーシカで理解しました。外側の人形を開けると、中に少し小さい人形がいて、また開けると…という、あの入れ子です。Flutterで「親」「子」と言うのは、この外側・内側の関係そのものです。
ここで大事なのが2種類のWidgetです。
- StatelessWidget: 一度描いたら自分では変わらない部品。ロゴやタイトルみたいな「飾り」。
- StatefulWidget: 中に変化する値(state)を持てる部品。カウンターやフォームみたいな「動くもの」。
Webから来た人がつまずくのは、ここです。HTMLなら要素を直接書き換えますが、Flutterは違う。値が変わったと宣言する(setStateを呼ぶ)と、Flutterがその部分のツリーを作り直して、画面を更新する。冒頭のボタンが動かなかったのは、値は増やしたのに「変わったよ」と宣言していなかったからでした。
| Web(React等)の感覚 | Flutterでの対応 | 初心者の典型ミス |
|---|---|---|
| DOMを直接更新 | ツリーを作り直して反映 | 値だけ変えてsetStateを呼ばない |
| コンポーネント | Widget(Stateless/Stateful) | 何でもStatefulにして重くする |
| useState | setState / ChangeNotifier | 状態を画面のあちこちに散らす |
| CSSで装飾 | Widgetのプロパティで装飾 | Containerを深く積みすぎる |
この対応表を頭に入れておくと、Claude Codeが書いたコードを読むときも「あ、ここで状態を作り直してるのか」と追えるようになります。
状態管理は「規模」で選ぶ。最初からRiverpodを入れない
「Dart 状態管理 Riverpod」で検索すると、いきなりRiverpodの設定から始まる記事が多くて、僕は最初これで遠回りしました。ひとことで言うと、状態管理ライブラリは画面の規模で選ぶもので、小さい画面に重い道具を入れるのは事故のもとです。
Flutter公式も、まずは標準のsetStateやValueNotifierから始めることをすすめていて、Riverpod や Bloc のようなライブラリは「コミュニティが提供する選択肢」という扱いです(State management options を参照)。つまり「みんな使ってるから」で入れるものではありません。僕の選び方はこうです。
| 状況 | 向いている方法 | ひとことで言うと |
|---|---|---|
| 1画面の中だけで完結する値 | setState | 一番軽い。迷ったらこれ |
| 1画面でも状態が増えてきた | ChangeNotifier | 状態を1クラスに集約できる |
| 複数画面で共有 / 依存注入したい | Riverpod | テストしやすく、グローバルに配れる |
| 既存プロジェクトが採用済み | それに合わせる | 統一が最優先。混ぜない |
ポイントは最後の行です。新規パッケージを入れる判断は人間がやる。Claude Codeには「既存の状態管理方針に合わせて。新しいパッケージは勝手に足さないで」と必ず書きます。これをサボると、pubspec.yamlにRiverpodとProviderとBlocが全部入った、地獄みたいなプロジェクトができあがります(一度やりました)。
まず動かす:依存ゼロのカート画面
説明より、動くものを見たほうが早いです。flutter create cart_ai_demoで作った直後のlib/main.dartに、まるごと貼り替えてください。追加パッケージは要りません。ChangeNotifierだけで状態管理をしています。CartControllerが「画面の状態を変える唯一の窓口」で、ここに変更を閉じ込めるのがコツです。
import 'package:flutter/material.dart';
void main() => runApp(const CartDemoApp());
class CartDemoApp extends StatelessWidget {
const CartDemoApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Cart AI Demo',
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
home: const CartSummaryPage(),
);
}
}
class CartLine {
const CartLine({required this.name, required this.price, required this.quantity});
final String name;
final int price;
final int quantity;
CartLine copyWith({int? quantity}) {
return CartLine(name: name, price: price, quantity: quantity ?? this.quantity);
}
}
// 状態を変える窓口。画面側はここしか触らない
class CartController extends ChangeNotifier {
final List<CartLine> _lines = const [
CartLine(name: 'Dart notebook', price: 1800, quantity: 1),
CartLine(name: 'Flutter sticker', price: 500, quantity: 2),
].toList();
List<CartLine> get lines => List.unmodifiable(_lines);
int get total => _lines.fold(0, (sum, line) => sum + line.price * line.quantity);
void increment(int index) {
final line = _lines[index];
_lines[index] = line.copyWith(quantity: line.quantity + 1);
notifyListeners(); // 「変わったよ」と画面に宣言する
}
void decrement(int index) {
final line = _lines[index];
if (line.quantity == 1) return; // 1個未満にはしない
_lines[index] = line.copyWith(quantity: line.quantity - 1);
notifyListeners();
}
}
class CartSummaryPage extends StatefulWidget {
const CartSummaryPage({super.key});
@override
State<CartSummaryPage> createState() => _CartSummaryPageState();
}
class _CartSummaryPageState extends State<CartSummaryPage> {
late final CartController controller;
@override
void initState() {
super.initState();
controller = CartController(); // build内ではなく、ここで1回だけ作る
}
@override
void dispose() {
controller.dispose(); // 後始末を忘れると小さなリークになる
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Cart summary')),
body: AnimatedBuilder(
animation: controller, // controllerが通知したら、この中だけ作り直す
builder: (context, _) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
for (final entry in controller.lines.indexed)
ListTile(
title: Text(entry.$2.name),
subtitle: Text('JPY ${entry.$2.price} x ${entry.$2.quantity}'),
trailing: Wrap(
spacing: 8,
children: [
IconButton(
tooltip: 'Decrease ${entry.$2.name}',
onPressed: () => controller.decrement(entry.$1),
icon: const Icon(Icons.remove_circle_outline),
),
IconButton(
tooltip: 'Increase ${entry.$2.name}',
onPressed: () => controller.increment(entry.$1),
icon: const Icon(Icons.add_circle_outline),
),
],
),
),
const Divider(),
Text(
'Total: JPY ${controller.total}',
style: Theme.of(context).textTheme.headlineSmall,
),
],
);
},
),
);
}
}
動かすのはこれだけ。
flutter create cart_ai_demo
cd cart_ai_demo
flutter pub get
flutter run
このコードの肝は3つです。CartControllerをbuildではなくinitStateで1回だけ作ること。状態変更のたびにnotifyListeners()を呼ぶこと。disposeで後始末すること。この3点を外すと、「ボタンを押しても増えない」「画面を開くたびに状態がリセットされる」という、初心者あるあるが全部発生します。
Riverpodに置き換えるとどう変わるか
カートを複数画面で共有したくなったら、Riverpodの出番です。Riverpodは状態を「ツリーの外」に置いて、必要な画面から読みに行く仕組みだと考えると分かりやすい。マトリョーシカの外側に、共有の棚をひとつ用意するイメージです。
公式は今のRiverpod 3系でNotifier/NotifierProviderを使う書き方を標準にしています(riverpod.dev)。さっきのChangeNotifierを置き換えると、状態クラスはこうなります。考え方は同じで、「状態を1か所に集約し、変わったら通知する」ところは変わりません。
import 'package:flutter_riverpod/flutter_riverpod.dart';
class CartLine {
const CartLine({required this.name, required this.price, required this.quantity});
final String name;
final int price;
final int quantity;
CartLine copyWith({int? quantity}) =>
CartLine(name: name, price: price, quantity: quantity ?? this.quantity);
}
// 状態とロジックをまとめたNotifier
class CartNotifier extends Notifier<List<CartLine>> {
@override
List<CartLine> build() => const [
CartLine(name: 'Dart notebook', price: 1800, quantity: 1),
CartLine(name: 'Flutter sticker', price: 500, quantity: 2),
];
void increment(int index) {
final next = [...state];
next[index] = next[index].copyWith(quantity: next[index].quantity + 1);
state = next; // 新しいリストを代入するとUIが更新される
}
}
// 画面からはこのproviderを読む
final cartProvider = NotifierProvider<CartNotifier, List<CartLine>>(CartNotifier.new);
final cartTotalProvider = Provider<int>((ref) {
final lines = ref.watch(cartProvider);
return lines.fold(0, (sum, l) => sum + l.price * l.quantity);
});
setStateから始めて、共有が必要になった時点でRiverpodへ移す。この順番なら、最初から重い設定に時間を溶かさずに済みます。Riverpodを使う場合はpubspec.yamlにflutter_riverpodを足し、アプリ全体をProviderScopeで包む必要があります。ここはパッケージ追加の判断なので、僕は人間が決める作業に入れています。
iOS/Androidを同時に進めるときの落とし穴
Flutterの一番おいしいところは、同じDartコードでiOSもAndroidもWebも動くことです。でも、共通化できない層がある。これを知らずに「全部Flutterで一発」と思うと、リリース直前に詰みます。
僕が実際に踏んだ地雷を、対象ごとに表にしました。
| 対象 | 共通化できない部分 | 僕がやらかしたこと |
|---|---|---|
| Android | AndroidManifest.xmlの権限、minSdk、署名 | カメラ権限を書き忘れて実機だけクラッシュ |
| iOS | Info.plistの説明文、CocoaPods、証明書 | 位置情報の説明文がなく審査リジェクト |
| Web | CORS、使えないブラウザAPI、画像パス | dart:ioをWebで呼んで起動エラー |
| 共通 | プラグインのプラットフォーム対応状況 | iOSだけ未対応のプラグインを入れていた |
ここでClaude Codeに任せるコツは、Dartコードだけでなくandroid/やios/の設定ファイルも一緒に見せることです。Dartだけ渡すと、Claude Codeは「画面はできました」と報告しますが、権限やplistは手つかずのまま。だから僕は「権限が必要な機能を列挙して、AndroidManifest.xmlとInfo.plistの差分を理由つきで先に出して」と頼みます。
ビルド確認は対象を明示します。Windowsで作業しているなら、iOSのrelease buildや署名はmacOSとXcodeが必要なので、そこは「できない」とプロンプトに書いておく。これを書かないと、Claude CodeはできないiOS確認まで「完了」と言いがちです。
flutter analyze
flutter test
flutter build apk --debug
flutter build web --release
iOS/Android同時開発の詳しい温度感は、考え方が近いReact Native開発の記事もあわせて読むと、クロスプラットフォームで「どこが共通でどこが別か」の感覚がつかめます。
テストまでセットで頼む
Flutterで一番コスパがいいのがwidget testです。画面を起動して、文字やボタン、タップ後の状態まで確認できます。実機がなくても、CIでも回る。さっきのカート画面なら、test/cart_summary_test.dartにこれを置きます。
import 'package:cart_ai_demo/main.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('数量を変えると合計が更新される', (tester) async {
await tester.pumpWidget(const CartDemoApp());
expect(find.text('Total: JPY 2800'), findsOneWidget); // 初期合計
await tester.tap(find.byIcon(Icons.add_circle_outline).first);
await tester.pump();
expect(find.text('Total: JPY 4600'), findsOneWidget); // 増やした後
await tester.tap(find.byIcon(Icons.remove_circle_outline).first);
await tester.pump();
expect(find.text('Total: JPY 2800'), findsOneWidget); // 戻した後
});
}
実行はこれだけです。
flutter analyze
flutter test
Claude Codeに実装を頼むときは、テストを同じ依頼に含めるのが鉄則です。「実装して」だけだと、テストは別の依頼になり、結局あとで自分が書くはめになる。僕はこう書きます。
CartSummaryPageの変更に対応するwidget testを追加してください。
初期合計、増加後、減少後の3点を確認してください。
実装後に flutter analyze と flutter test を実行し、
失敗したら原因をファイル名つきで報告してください。
テストの優先順位の決め方そのものは、テスト戦略の記事に分けて書いています。「どこから書くか」で迷ったらそちらを。
Claude CodeにFlutterを書かせるときのコツ
最後に、僕がFlutter開発でClaude Codeに渡している指示のパターンをまとめます。一度に「画面・API・状態管理・プラットフォーム設定・テスト」を全部頼むと、差分が膨らんでレビューが破綻します。だから段階に分けます。
- 調査だけ: 「このFlutterプロジェクトを読んで、
lib//test//pubspec/プラットフォーム設定の地図を作って。編集は禁止」。まず地図を作らせる。 - 範囲を絞った実装: 「
lib/features/cart配下だけ変更。状態管理は既存方針に合わせ、新規依存は足さない」。 - pubspec判断: 「依存追加が必要なら、編集前に理由・代替案・影響範囲・確認コマンドを先に出して」。勝手に
pubspec.yamlを触らせない。 - テスト: widget testを追加させ、
flutter analyzeとflutter testの結果まで報告させる。 - 批判的レビュー: 「
build内の状態生成、dispose漏れ、非同期処理、プラットフォーム差分、不要なパッケージ追加を批判的に見て」。
この「地図 → 小さな差分 → テスト」の順番は、プロジェクト規約を渡しておくとさらに安定します。何をルール化すべきかはCLAUDE.mdの書き方に詳しくまとめてあります。
よくある質問
Q. ボタンを押しても画面が変わりません。なぜ?
値を変えただけで「変わった」と宣言していないからです。setStateの中で値を更新するか、ChangeNotifierならnotifyListeners()を呼んでください。冒頭の僕の知人と同じパターンです。
Q. 最初からRiverpodを覚えたほうがいいですか?
いいえ。1画面で完結するならsetState、状態が増えたらChangeNotifierで十分です。複数画面で共有が必要になってからRiverpodに移すほうが、学習コストも事故も減ります。
Q. iOSの確認をWindowsだけで完結できますか?
release buildや署名はmacOSとXcodeが必要です。Windowsではflutter analyze/flutter test/flutter build apkまで。Claude Codeにも「iOS署名は環境がないのでやらないで」と明記してください。
Q. flutter_lintsのバージョンはどれを使えばいい?
2026年6月時点の最新は6.0.0です。flutter createで生成されるpubspec.yamlのdev_dependenciesにflutter_lints: ^6.0.0が入っていれば、そのままで問題ありません。
Q. WidgetをStatelessとStatefulのどちらにすべき? 中で値が変化しないならStateless、変化する状態を持つならStatefulです。迷ったらStatelessで作り、状態が必要になったら昇格させる。全部Statefulにすると、無駄に再描画が増えます。
実際に試した結果
冒頭の知人には、その場で「値を変えたあとにsetStateを呼んでみて」と伝えました。一行足しただけでカウンターが動き出して、彼は「これだけ…?」と笑っていました。Flutterのつまずきの大半は、能力じゃなくて「画面の更新の仕組みを知っているか」なんですよね。
僕がこの順番——ウィジェットツリーを入れ子で理解する → setState/ChangeNotifierから始める → 共有が要るときだけRiverpod → テストとビルドで締める——に落ち着いてから、Claude Codeに任せたFlutterの差分が一気にレビューしやすくなりました。逆に「いい感じにFlutterで作って」と丸投げした日は、pubspec.yamlの無断変更とプラットフォーム設定の見落としで、毎回やり直しでした。
Flutterは速い。でも「公開できる速さ」にするのは、範囲を区切る指示と、公式ドキュメントの確認と、テストの組み合わせです。チームで導入の基準まで整えたいならClaude Code研修・導入相談を、まず一人で試すなら上のカート画面をflutter runするところから始めてみてください。
無料PDF: Claude Code はじめてのチートシート
まずは無料PDFで基本コマンドと最初の使い方をまとめて確認してください。登録後はそのままテンプレート集や導入相談にも進めます。
スパムは送りません。登録情報は厳重に管理します。
Claude Codeを仕事で使える形にしませんか?
まず無料PDFで基本を固め、繰り返し使う作業はGumroad教材へ、チーム導入や権限設計は導入相談へ進めます。
この記事を書いた人
Masa
Claude Codeの実務活用、導入設計、収益導線改善を検証しているエンジニア。10言語の技術メディアを運営中。
関連書籍・参考図書
この記事のテーマに関連する書籍を楽天ブックスで探せます。
※ 当サイトは楽天市場のアフィリエイトプログラムに参加しています。上記リンクから商品をご購入いただくと、運営者に紹介料が支払われる場合があります。
関連記事
制作会社がClaude Codeに触らせる前に決める権限チェックリスト
クライアントサイトを壊さずにAI編集を使うための、制作会社向け権限と確認の型です。
SaaSサポートのバグ報告をClaude Codeで再現手順に変える実務フロー
問い合わせ文をそのまま開発へ投げず、再現手順、証拠、次の一手に整えるサポート向け手順です。
Obsidianの古いメモをClaude Codeの指示書に変える10分ルーチン
Obsidianに溜めたメモが毎回ゴミになる人へ。事実・決定・未確認に仕分けして、Claude Codeがそのまま動ける指示書に変える朝の10分の型を紹介します。