Custom Widgets & Custom Painting

Reusable Composite Widgets

15 min Lesson 1 of 12

Reusable Composite Widgets

As Flutter applications grow, you will notice the same UI patterns repeated across multiple screens. A profile card here, a labeled text field there, a status badge in three different places. Copying and pasting widget trees leads to maintenance nightmares: a single design change requires updating every duplicate. The solution is to extract repeated UI trees into standalone, named widget classes with clearly typed constructor parameters.

This lesson teaches you how to identify extraction opportunities, decide between StatelessWidget and StatefulWidget, write clean constructor signatures, and understand when composition is preferable to inheritance.

Why Extract Into a Widget Class?

  • Single source of truth — change the design once, every usage updates automatically.
  • ReadabilityProfileCard(user: user) communicates intent far better than 20 lines of nested Card/Row/Column inline.
  • Testability — small, focused widgets are trivial to unit- and widget-test in isolation.
  • Rebuild efficiency — Flutter can short-circuit rebuilds on widgets whose == comparison shows no change, which is only possible when you have named, const-constructable widgets.
Note: Flutter discourages splitting widgets into helper methods (e.g. Widget _buildHeader()) when a separate class would do. Methods always rebuild with their parent; a separate class gives Flutter the opportunity to skip the rebuild entirely.

StatelessWidget: Pure Presentation

Use StatelessWidget when the widget's appearance is entirely determined by its constructor arguments and the build context. It has no internal mutable data. Every property you receive from the outside becomes a final field, and you expose them through a const-capable constructor.

Example — InfoBadge (StatelessWidget)

import 'package:flutter/material.dart';

/// A small coloured badge used to display a status label.
/// Fully determined by its constructor arguments — no internal state.
class InfoBadge extends StatelessWidget {
  final String label;
  final Color color;
  final Color textColor;

  const InfoBadge({
    super.key,
    required this.label,
    this.color = Colors.blue,
    this.textColor = Colors.white,
  });

  @override
  Widget build(BuildContext context) {
    return Container(
      padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
      decoration: BoxDecoration(
        color: color,
        borderRadius: BorderRadius.circular(12),
      ),
      child: Text(
        label,
        style: TextStyle(
          color: textColor,
          fontSize: 12,
          fontWeight: FontWeight.w600,
        ),
      ),
    );
  }
}

// Usage — same widget, three different contexts:
InfoBadge(label: 'New',    color: Colors.green)
InfoBadge(label: 'Sale',   color: Colors.orange)
InfoBadge(label: 'Sold',   color: Colors.grey)

Notice the const constructor. When Flutter sees const InfoBadge(label: 'New', color: Colors.green) it can reuse the same object across rebuilds, entirely skipping the build step for that subtree.

StatefulWidget: Self-Contained Interactivity

Use StatefulWidget when the widget must track its own mutable data — data that lives inside the widget and does not need to be shared with the outside world. Classic examples include an expandable panel, a favourite-toggle button, or an animated counter.

Example — ExpandableSection (StatefulWidget)

import 'package:flutter/material.dart';

/// A collapsible content section that manages its own open/closed state.
class ExpandableSection extends StatefulWidget {
  final String title;
  final Widget child;

  const ExpandableSection({
    super.key,
    required this.title,
    required this.child,
  });

  @override
  State<ExpandableSection> createState() => _ExpandableSectionState();
}

class _ExpandableSectionState extends State<ExpandableSection> {
  bool _isExpanded = false;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        InkWell(
          onTap: () => setState(() => _isExpanded = !_isExpanded),
          child: Row(
            children: [
              Expanded(
                child: Text(
                  widget.title,
                  style: Theme.of(context).textTheme.titleMedium,
                ),
              ),
              Icon(_isExpanded ? Icons.expand_less : Icons.expand_more),
            ],
          ),
        ),
        if (_isExpanded)
          Padding(
            padding: const EdgeInsets.only(top: 8),
            child: widget.child,
          ),
      ],
    );
  }
}

// Usage:
ExpandableSection(
  title: 'Shipping Details',
  child: Text('Arrives in 3–5 business days.'),
)
Tip: Access constructor arguments inside the State class through widget.propertyName — for example widget.title above. The widget getter is provided by the State<T> base class and always returns the current widget instance.

Composing vs Inheriting

Flutter's widget model is built around composition — wrapping and combining existing widgets — rather than classical inheritance. You almost never subclass Container, Row, or any concrete widget. Instead, you build a new class that contains those widgets as part of its build method.

  • Prefer composition when you want to assemble a new UI from existing primitives.
  • Use inheritance only when you genuinely need to override framework behaviour, such as creating a custom RenderObject or a custom ScrollActivity.
  • For sharing logic between widgets without inheritance, extract the logic into a plain Dart class or a mixin, then inject it via the constructor.
Warning: Extending concrete widgets like class MyCard extends Card looks tempting but breaks quickly — Flutter's concrete widgets are not designed as stable base classes and their internal APIs change across versions. Always compose; never extend concrete widgets.

Typed Constructor Parameters — Best Practices

A composite widget is only as good as its interface. Follow these guidelines when designing constructor parameters:

  • Mark every parameter that the widget cannot function without as required.
  • Provide sensible defaults for optional styling parameters so callers don't need to specify them every time.
  • Accept callbacks as typed VoidCallback or ValueChanged<T> rather than generic Function.
  • Accept child content as a Widget (or List<Widget>) parameter rather than building it internally when flexibility is needed.
  • Add a Key? key parameter forwarded to super.key so the widget participates correctly in the element reconciliation algorithm.

Summary

Extracting repeated UI trees into named StatelessWidget or StatefulWidget subclasses is one of the most impactful habits you can develop as a Flutter developer. It produces readable, maintainable, and efficiently-rendering UIs. Use StatelessWidget for pure display driven by constructor arguments, and StatefulWidget when the widget owns self-contained mutable state. Always compose widgets rather than extending them, and design constructor signatures that are explicit, typed, and provide sensible defaults.