Flutter Widget Splitting
Flutter + Riverpod の Widget 分割と共通化を、実装までつながる形で支援する。
対象
- Flutter UI
flutter_riverpod/riverpod- 巨大な
build()/ConsumerWidget/ 画面単位のリファクタ - helper method ベースの UI を Widget 化したいケース
開始時に確認する
- 対象ファイル、画面、Widget を特定する
- 状態管理が Flutter ローカル state か Riverpod かを確認する
- 変更目的を1行で固定する。可読性、再利用、再ビルド境界、テスト性のどれを優先するか明確にする
- 分割位置の判定に迷う場合は references/decomposition-checklist.md を読む
- Riverpod の
watch/read/listen/select境界に迷う場合は references/riverpod-boundaries.md を読む
基本原則
- 行数ではなく、責務と変化の境界で分割する
- 再利用や再ビルド最適化が欲しい UI は helper method ではなく Widget にする
ref.watchは値を使う最小の Widget まで下ろす- 共有された業務状態は Provider に置き、一時 UI 状態はローカルに置く
- 副作用は
ref.listen、書き込みはイベントハンドラ内のref.readに寄せる const化と builder 系 Widget を優先する
ワークフロー
Step 1: build() の中身を4種類に色分けする
- 純 UI:
Text,Padding,Row,Card, 装飾 - 共有状態:
ref.watch(...)や ViewModel / Notifier の状態 - 一時 UI 状態: 展開、タブ、入力途中、controller、animation
- 副作用: snackbar, dialog, navigation, submit
Step 2: 状態の置き場を固定する
- 共有された業務状態:
Provider/Notifier/AsyncNotifier - 一時 UI 状態:
StatefulWidget/ hooks / controller - 副作用:
ref.listen - 書き込み:
ref.read(provider.notifier).method()
一時 UI 状態を Provider に入れたくなったら、まず route をまたいで共有すべきかを疑う。
TextEditingController やフォーム入力途中の値は原則ローカルに置く。
Step 3: 分割レイヤーを決める
以下の4層で切る。
Page:Scaffoldと画面全体の骨組みSection: 意味のある塊。header, list, action bar などComponent: 再利用できる部品。card, row, tile, button groupLeaf:constにしやすい最小の表示部品
Step 4: Riverpod 境界を切る
- 画面ルートで大きく
watchしない - provider を読む Widget は
ConsumerWidget/Consumerとして局所化する - 一部プロパティしか使わないなら
select - 非同期 provider の一部だけ必要なら
selectAsync ref.listenは effect 専用 Widget か画面境界に置く
Step 5: 下から順に抽出する
constにできる純 UI をStatelessWidget化する- provider を読む Section を
ConsumerWidget化する - 一時 UI 状態を持つ箇所だけ
StatefulWidgetに閉じ込める - helper method を、責務のある Widget に置き換える
build()内の整形や条件分岐が重い場合は Provider / Notifier 側へ移す
Step 6: 共通化は2段階で行う
- まずは画面専用の private widget に切り出す
- 2画面目や3箇所目で再利用が見えたら shared component に昇格する
最初から CommonCard や BaseTile のような抽象度の高い共通Widgetを作らない。
変わる軸が見えた後で API を作る。
Step 7: 仕上げに確認する
Pageが provider を読みすぎていないかwatchが最小境界にあるかlistenとreadの役割が分かれているかfamily/ 引数付き provider が不要に保持されていないかconstと builder 系を落としていないかflutter analyze/flutter testがあれば回す
典型パターン
helper method を Widget にする
class ProfilePage extends StatelessWidget {
const ProfilePage({super.key});
@override
Widget build(BuildContext context) {
return const Column(
children: [
ProfileHeader(),
ProfileStats(),
],
);
}
}
provider 読み取りを Section に閉じ込める
class ProfileStats extends ConsumerWidget {
const ProfileStats({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final followers = ref.watch(profileProvider.select((it) => it.followers));
final posts = ref.watch(profileProvider.select((it) => it.posts));
return StatsRow(followers: followers, posts: posts);
}
}
副作用は listen、更新は read
class SaveProfileButton extends ConsumerWidget {
const SaveProfileButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.listen(saveProfileProvider, (_, next) {
next.whenOrNull(
data: (_) => ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Saved')),
),
);
});
return FilledButton(
onPressed: () => ref.read(saveProfileProvider.notifier).save(),
child: const Text('Save'),
);
}
}
禁止寄りのパターン
- 画面ルートで巨大な
ConsumerWidgetを作り、複数 provider をまとめてwatchする readで再ビルドを無理に避ける- controller やフォーム途中値を Provider に載せる
- 副作用を
build()やwatchと混ぜる - 再利用したい UI を
_buildHeader()のような method のまま維持する - 早すぎる汎用化で抽象度だけ高い共通Widgetを作る
出力
最終的に以下を返す。
- どこで分割するか
- 各 Widget の責務
- Provider / Widget の状態所有
watch/read/listen/selectの配置- 必要ならリファクタ後のコード差分