公開日 2026-07-07

Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用)

Flutter の StatelessWidget 分割を最小例で整理し、親から子への値渡しと再利用の判断基準を持てるようにする。

目次

  1. 1. ゴールと非対象
  2. 対象読者
  3. この記事で到達する状態
  4. 非対象
  5. 2. まずは分割の目的を 3 つに絞る
  6. 3. まずは 1 つの build に詰め込んだ状態を見る
  7. コードのポイント
  8. コードのポイント
  9. 5. プロパティで値を渡すと再利用しやすくなる
  10. コードのポイント
  11. 6. いつ分割するかを 3 つの観点で決める
  12. 7. まとめ

FlutterのTabBarとBottomNavigationBarで複数画面を切り替える の次に、画面を作り足し始めると早めに出てくるのが「lib/main.dart が長くなって、どこを直せばよいか追いにくい」という詰まりどころです。ここで止まりやすいのは StatelessWidget という名前ではなく、「どの単位で分けると読みやすくなるか」が見えにくい点にあります。この記事では、1 つの画面を StatelessWidget へ分割する流れ、親から子へ値を渡す方法、分割タイミングの判断基準を最小構成で整理します。

1. ゴールと非対象

対象読者

  • Flutter の環境構築、Dart 入門、基本レイアウト、一覧 UI、確認 UI、タブ切り替えの基本までは終わった人
  • 1 画面の UI をそのまま build へ書き続けていて、どこで切り出すべきか迷いやすい人
  • StatelessWidget を見たことはあるが、自分で部品を作る場面がまだ曖昧な人

この記事で到達する状態

  • 見た目のまとまりごとに StatelessWidget を切り出せる
  • final フィールドとコンストラクタ引数で親から子へ値を渡せる
  • ボタン押下などの処理を親から callback で渡せる
  • 「いつ分割するか」を 3 つの観点で判断できる

非対象

  • StatefulWidget のライフサイクル詳細
  • Riverpod などの状態管理
  • 複数ファイルへの配置設計
  • 高度なパフォーマンス最適化

今回の主題は、状態管理ではありません。まずは 1 画面の中で、読みやすい部品名を付けながら UI を分けるところまでが対象です。ここが整理できると、次に REST API 通信やフォームへ進んだときも、画面構成と処理の置き場を分けやすくなります。

2. まずは分割の目的を 3 つに絞る

カスタムウィジェットへ分割する目的は、次の 3 つです。

目的何が良くなるか典型例
可読性親の build で画面構成だけ追えるヘッダー、カード一覧、操作ボタン群
再利用引数違いで同じ見た目を使い回せるステータスカード、一覧行、ラベル
責務分離表示と処理の置き場を分けやすい見た目は子、押下時の処理は親

分割は「行数を減らすため」だけではありません。名前を付けた部品として切り出し、親の build を「どんな画面か読む場所」に変えるためです。

判断の流れを先に置くと、次の通りです。

flowchart TD
  A[このUIはどこで区切るか] --> B{見た目のまとまりがあるか}
  B -->|はい| C[Widget名を付けて切り出す]
  B -->|いいえ| D{同じ形が2回以上出るか}
  D -->|はい| C
  D -->|いいえ| E{引数違いで再利用できるか}
  E -->|はい| C
  E -->|いいえ| F[親のbuildに残す]

以降のコード例はすべて lib/main.dart にそのまま貼って flutter run で確認できます。外部パッケージ不要のため、DartPad でも試せます。

3. まずは 1 つの build に詰め込んだ状態を見る

分割の必要性は、長いコードを見たほうが分かりやすくなります。次の lib/main.dart は動きますが、どこからどこまでが 1 つのまとまりか読み取りにくい状態です。

このファイルは、出荷概要と優先タスクを 1 つの build に詰め込んだ状態を再現するサンプルです。注目点は、画面の骨格、重複した UI、押下処理が同じ階層へ混ざっていることにあります。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Shipment Dashboard')),
        body: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: const Color(0xFFE8F3FF),
                borderRadius: BorderRadius.circular(16),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    '今日の出荷概要',
                    style: TextStyle(
                      fontSize: 20,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 8),
                  const Text('締切まで 2 時間。未処理の伝票を先に確認します。'),
                  const SizedBox(height: 16),
                  Row(
                    children: [
                      Expanded(
                        child: Container(
                          padding: const EdgeInsets.all(12),
                          decoration: BoxDecoration(
                            color: Colors.white,
                            borderRadius: BorderRadius.circular(12),
                          ),
                          child: const Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Text('未処理'),
                              SizedBox(height: 4),
                              Text(
                                '12 件',
                                style: TextStyle(
                                  fontSize: 24,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                      const SizedBox(width: 12),
                      Expanded(
                        child: Container(
                          padding: const EdgeInsets.all(12),
                          decoration: BoxDecoration(
                            color: Colors.white,
                            borderRadius: BorderRadius.circular(12),
                          ),
                          child: const Column(
                            crossAxisAlignment: CrossAxisAlignment.start,
                            children: [
                              Text('要確認'),
                              SizedBox(height: 4),
                              Text(
                                '3 件',
                                style: TextStyle(
                                  fontSize: 24,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),
                    ],
                  ),
                ],
              ),
            ),
            const SizedBox(height: 16),
            Container(
              padding: const EdgeInsets.all(16),
              decoration: BoxDecoration(
                color: Colors.white,
                borderRadius: BorderRadius.circular(16),
                border: Border.all(color: const Color(0xFFD6DCE5)),
              ),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  const Text(
                    '優先タスク',
                    style: TextStyle(
                      fontSize: 18,
                      fontWeight: FontWeight.bold,
                    ),
                  ),
                  const SizedBox(height: 12),
                  ListTile(
                    contentPadding: EdgeInsets.zero,
                    leading: const CircleAvatar(child: Text('1')),
                    title: const Text('出荷待ち伝票を確認する'),
                    subtitle: const Text('未処理 12 件'),
                    trailing: FilledButton(
                      onPressed: () {},
                      child: const Text('開く'),
                    ),
                  ),
                  ListTile(
                    contentPadding: EdgeInsets.zero,
                    leading: const CircleAvatar(child: Text('2')),
                    title: const Text('保留理由を確認する'),
                    subtitle: const Text('要確認 3 件'),
                    trailing: OutlinedButton(
                      onPressed: () {},
                      child: const Text('確認'),
                    ),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }
}

コードのポイント

① 画面の骨格と装飾が親の build に混ざっている

    return MaterialApp(
      home: Scaffold(
        body: ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Container(

親の build で一覧の骨格を読みたい場面なのに、最初の ListView に入った瞬間から装飾用の Container と細かな余白指定が続きます。どの塊が「出荷概要」で、どの塊が「優先タスク」かを名前で追えないため、画面構成より先に装飾コードへ視線が取られます。

② 同じ見た目のカードがその場で重複している

Expanded(
  child: Container(
    padding: const EdgeInsets.all(12),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(12),
    ),
    child: const Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text('未処理'),
        SizedBox(height: 4),
        Text('12 件'),
      ],
    ),
  ),
),
Expanded(
  child: Container(

未処理要確認 のカードは、ラベルと値以外の構造がほぼ同じです。この段階では動作に問題はありませんが、見た目の共通部分を名前付き Widget にしていないため、修正箇所が増えるほど差分を追いにくくなります。

タスク行の見た目と押下処理まで親の build に混ざっているため、画面がもう少し増えると「画面構成」を読む前に細かい装飾コードへ目が止まります。ここで、見た目のまとまりごとに名前付き Widget へ切り出す価値が出てきます。

分割前のサンプル画面 ## 4. 見た目のまとまりごとに `StatelessWidget` へ切り出す

次は、同じ画面を ShipmentSummarySection MetricCard TaskPanel に分けます。親の build が「画面全体の構成だけ読める状態」になることが目的です。

lib/main.dart は次の内容で作成します。

このファイルは、1 つの長い build を役割ごとの StatelessWidget に分け直した版です。注目点は、親では画面構成だけを残し、共通 UI を MetricCardTaskRow として切り出していることです。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Shipment Dashboard')),
        body: ListView(
          padding: const EdgeInsets.all(16),
          children: const [
            ShipmentSummarySection(),
            SizedBox(height: 16),
            TaskPanel(),
          ],
        ),
      ),
    );
  }
}

class ShipmentSummarySection extends StatelessWidget {
  const ShipmentSummarySection({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: const Color(0xFFE8F3FF),
        borderRadius: BorderRadius.circular(16),
      ),
      child: const Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            '今日の出荷概要',
            style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
          ),
          SizedBox(height: 8),
          Text('締切まで 2 時間。未処理の伝票を先に確認します。'),
          SizedBox(height: 16),
          Row(
            children: [
              Expanded(child: MetricCard(label: '未処理', value: '12 件')),
              SizedBox(width: 12),
              Expanded(child: MetricCard(label: '要確認', value: '3 件')),
            ],
          ),
        ],
      ),
    );
  }
}

class MetricCard extends StatelessWidget {
  const MetricCard({
    super.key,
    required this.label,
    required this.value,
  });

  final String label;
  final String value;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(12),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(label),
          const SizedBox(height: 4),
          Text(
            value,
            style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
          ),
        ],
      ),
    );
  }
}

class TaskPanel extends StatelessWidget {
  const TaskPanel({super.key});

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: const Color(0xFFD6DCE5)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: const [
          Text(
            '優先タスク',
            style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
          ),
          SizedBox(height: 12),
          TaskRow(
            number: '1',
            title: '出荷待ち伝票を確認する',
            subtitle: '未処理 12 件',
            actionLabel: '開く',
            useFilledButton: true,
          ),
          TaskRow(
            number: '2',
            title: '保留理由を確認する',
            subtitle: '要確認 3 件',
            actionLabel: '確認',
            useFilledButton: false,
          ),
        ],
      ),
    );
  }
}

class TaskRow extends StatelessWidget {
  const TaskRow({
    super.key,
    required this.number,
    required this.title,
    required this.subtitle,
    required this.actionLabel,
    required this.useFilledButton,
  });

  final String number;
  final String title;
  final String subtitle;
  final String actionLabel;
  final bool useFilledButton;

  @override
  Widget build(BuildContext context) {
    final button = useFilledButton
        ? FilledButton(onPressed: () {}, child: Text(actionLabel))
        : OutlinedButton(onPressed: () {}, child: Text(actionLabel));

    return ListTile(
      contentPadding: EdgeInsets.zero,
      leading: CircleAvatar(child: Text(number)),
      title: Text(title),
      subtitle: Text(subtitle),
      trailing: button,
    );
  }
}

コードのポイント

① 親の build は画面構成だけを読む形になっている

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Shipment Dashboard')),
        body: ListView(
          padding: const EdgeInsets.all(16),
          children: const [
            ShipmentSummarySection(),
            SizedBox(height: 16),
            TaskPanel(),
          ],
        ),
      ),
    );

親の build で見るべき情報が、ShipmentSummarySectionTaskPanel という役割名に集約されました。これで「何を並べている画面か」を先に把握し、そのあと必要な部品だけを開いて追える構成になります。

② 共通 UI は名前付き Widget と引数へ寄せている

class MetricCard extends StatelessWidget {
  const MetricCard({
    super.key,
    required this.label,
    required this.value,
  });

  final String label;
  final String value;

MetricCard が必要な入力を labelvalue に絞って受け取るため、同じ見た目を保ったまま中身だけ差し替えられます。StatelessWidget へ切り出すときは、まず「この塊に名前を付けたいか」で考えると判断しやすく、SummarySectionTaskPanel のように役割が読めるなら分割する価値があります。

5. プロパティで値を渡すと再利用しやすくなる

次は、親がデータと押下処理を持ち、子の ShipmentCard は表示だけを担当する形にします。これが StatelessWidget でよく使う基本形です。

lib/main.dart は次の内容で作成します。

このファイルは、親がデータとイベントを持ち、子が表示に専念する基本形を確認するサンプルです。注目点は、ShipmentCard に必要な表示値と onOpen だけを渡して責務を分けていることです。

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    final shipments = [
      const ShipmentCardData(
        title: '東京営業所向け',
        status: '未処理',
        itemCount: 12,
        accentColor: Color(0xFFE8F3FF),
      ),
      const ShipmentCardData(
        title: '名古屋センター向け',
        status: '保留あり',
        itemCount: 3,
        accentColor: Color(0xFFFFF4E5),
      ),
      const ShipmentCardData(
        title: '大阪倉庫向け',
        status: '完了間近',
        itemCount: 5,
        accentColor: Color(0xFFEAF8EC),
      ),
    ];

    return MaterialApp(
      home: Scaffold(
        appBar: AppBar(title: const Text('Shipment Cards')),
        body: ListView.separated(
          padding: const EdgeInsets.all(16),
          itemCount: shipments.length,
          separatorBuilder: (_, __) => const SizedBox(height: 12),
          itemBuilder: (context, index) {
            final shipment = shipments[index];
            return ShipmentCard(
              title: shipment.title,
              status: shipment.status,
              itemCount: shipment.itemCount,
              accentColor: shipment.accentColor,
              onOpen: () {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('${shipment.title} を開きます')),
                );
              },
            );
          },
        ),
      ),
    );
  }
}

class ShipmentCardData {
  const ShipmentCardData({
    required this.title,
    required this.status,
    required this.itemCount,
    required this.accentColor,
  });

  final String title;
  final String status;
  final int itemCount;
  final Color accentColor;
}

class ShipmentCard extends StatelessWidget {
  const ShipmentCard({
    super.key,
    required this.title,
    required this.status,
    required this.itemCount,
    required this.accentColor,
    required this.onOpen,
  });

  final String title;
  final String status;
  final int itemCount;
  final Color accentColor;
  final VoidCallback onOpen;

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.all(16),
      decoration: BoxDecoration(
        color: Colors.white,
        borderRadius: BorderRadius.circular(16),
        border: Border.all(color: const Color(0xFFD6DCE5)),
      ),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Container(
            padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
            decoration: BoxDecoration(
              color: accentColor,
              borderRadius: BorderRadius.circular(999),
            ),
            child: Text(
              status,
              style: const TextStyle(fontWeight: FontWeight.bold),
            ),
          ),
          const SizedBox(height: 12),
          Text(
            title,
            style: Theme.of(context).textTheme.titleMedium,
          ),
          const SizedBox(height: 8),
          Text('対象伝票: $itemCount 件'),
          const SizedBox(height: 16),
          SizedBox(
            width: double.infinity,
            child: FilledButton(
              onPressed: onOpen,
              child: const Text('詳細を開く'),
            ),
          ),
        ],
      ),
    );
  }
}

コードのポイント

① 親は並べるデータと押下時の処理を決めている

          itemBuilder: (context, index) {
            final shipment = shipments[index];
            return ShipmentCard(
              title: shipment.title,
              status: shipment.status,
              itemCount: shipment.itemCount,
              accentColor: shipment.accentColor,
              onOpen: () {
                ScaffoldMessenger.of(context).showSnackBar(
                  SnackBar(content: Text('${shipment.title} を開きます')),
                );
              },
            );

親は「どのデータを並べるか」と「押されたら何をするか」だけを決めています。ShipmentCard 自体は通知の出し方を知らず、一覧の文脈を持つ親が onOpen で振る舞いを注入する構造です。

② 子は表示に必要な材料だけを受け取る

class ShipmentCard extends StatelessWidget {
  const ShipmentCard({
    super.key,
    required this.title,
    required this.status,
    required this.itemCount,
    required this.accentColor,
    required this.onOpen,
  });

StatelessWidget に渡す引数は、その部品が表示に必要な材料へ絞ると役割が明確になります。今回なら title status itemCount accentColor onOpen で足りるため、親しか使わない処理の詳細まで子へ持ち込まずに済みます。callback を渡せば、子は見た目を保ったまま押下時の処理だけ親へ委ねられます。

ShipmentCard サンプル画面

6. いつ分割するかを 3 つの観点で決める

実装中に迷ったら、次の 3 つで判断すると止まりにくくなります。

観点分割を考えるサイン
見た目のまとまりその部分に名前を付けたいUserProfileHeader ShipmentCard ActionToolbar
重複同じ形の UI が 2 回以上出るメトリクスカード、一覧行、ラベル付きボタン
引数差し替え中身だけ変えて再利用できるタイトル、件数、色、押下処理だけ違うカード

反対に、次のような場面では無理に切り出さなくて構いません。

まだ分割しなくてよい場面理由
1 回しか出ず、数行で意味が明確親で読んだほうが流れを追いやすいため
名前を付けてもかえって曖昧になるSection1 のような抽象名では可読性が上がらないため
引数が多すぎて責務が混ざっているその部品の切り方自体を見直したほうがよいため

分割しすぎを避けたいなら、親の build を上から読んだときに「画面の構成」が見えるかを確認します。小さすぎる部品へ割って逆に流れが見えなくなるなら、まだその段階ではありません。

もう 1 つの目安は、「その Widget 名を聞いて、何を表示する部品か想像できるか」です。ShipmentCardTaskPanel なら役割が分かります。CustomBox1 のような名前しか付かないなら、切り方が曖昧な可能性があります。

7. まとめ

StatelessWidget での分割は、難しい設計の話から始めなくて構いません。まずは 1 画面の中で見た目のまとまりに名前を付け、親から子へ必要な値だけ渡すところから始めれば十分です。

迷ったときは、次の順で見ます。

  • 名前を付けたい見た目のまとまりがあるか
  • 同じ UI が 2 回以上出るか
  • 引数を変えるだけで再利用できるか

この 3 つに当てはまるなら、StatelessWidget へ切り出す価値があります。次に REST API 通信やフォーム画面へ進むときも、この分割を先にしておくと、表示と処理の境界を保ちやすくなります。

シリーズ 15/38

このシリーズ

Flutter導入と基礎

  1. 1. Windows 11で始めるFlutter開発環境:Android Emulatorで動かすまで
  2. 2. Flutter + FVM で開発環境のバージョンを固定する
  3. 3. Flutterで画像・SVG・アイコンを管理する(flutter_gen最小構成)
  4. 4. Flutterで最初に詰まりやすいDartの書き方:final・const・null safety・async/await を最初に整理する
  5. 5. DartのStream入門(非同期データの流れをつかむ)
  6. 6. FlutterのWidgetライフサイクル入門(initState / dispose で詰まらないために)
  7. 7. FlutterでBuildContextとKeyを理解する
  8. 8. Flutterのレイアウト入門(Column / Row / Stack の使い分け)
  9. 9. Flutterのテーマ設計入門(ThemeData + Theme Extension)
  10. 10. FlutterでMediaQueryとLayoutBuilderを使って画面サイズに対応する(スマホ・タブレット両対応)
  11. 11. FlutterのContainerとSizedBoxを使いこなす(余白・サイズ・装飾の基本)
  12. 12. FlutterのListViewとGridViewで一覧画面を作る(基本パターン)
  13. 13. Flutterのダイアログ・スナックバー・ボトムシートを使う(確認・通知UIの基本)
  14. 14. FlutterのTabBarとBottomNavigationBarで複数画面を切り替える
  15. 15. Flutterでカスタムウィジェットを作る入門(StatelessWidget の分割と再利用) 現在の記事
  16. 16. Flutterのルーティング入門(Navigator と go_router の使い分け)
  17. 17. FlutterからREST APIを呼ぶ最小構成(JSON通信 + エラー処理)
  18. 18. Flutterでjson_serializable + build_runnerを使ってJSONモデルを型安全に扱う
  19. 19. Flutterの状態管理入門(Riverpod最小構成)
  20. 20. Flutterでローディング・空状態・エラー表示を整える
  21. 21. Dart 3のsealed classとパターンマッチングで分岐を安全に書く
  22. 22. Flutterでgo_routerの認証ガードを実装する(redirect最小構成)
  23. 23. Flutterで端末設定と利用者設定を保存する(SharedPreferencesとsecure storageの使い分け)
  24. 24. Flutterでログイン状態を保持する(JWT + secure storage 最小構成)
  25. 25. Flutterアプリを日本語化する(l10n + arb 最小構成)
  26. 26. Flutterで業務用バーコード読み取りアプリを作る(最小構成)
  27. 27. Flutterでスキャン入力を受けて処理する
  28. 28. FlutterでGS1-128バーコードを解析する
  29. 29. Flutterで単一画面の入力フローを作る
  30. 30. Flutterで複数画像の添付UIを作る
  31. 31. Flutterでデータをファイルに書き出す
  32. 32. permission_handler でAndroid権限を実践的に扱う(カメラ・ストレージ・Bluetooth)
  33. 33. Flutterアプリのネイティブ設定を整える(アプリ名 / アイコン / スプラッシュ / 署名)
  34. 34. Flutterの環境切替と配布前チェック(flavor / release build / 権限確認)
  35. 35. FlutterのWidgetテスト入門(画面ロジックを壊さない最小構成)
  36. 36. FlutterのIntegration Test入門(ログインから一覧表示まで確認する)
  37. 37. Flutterアプリを社内配布する(Android APK サイドロード + MDM 概要)
  38. 38. Sentryでクラッシュとエラーを検知する(Flutter最小構成)