Flutter custom widgets: composition over inheritance
By Ann Tech · 25 July 2025
Composition over inheritance means building complex widgets by combining simpler ones rather than subclassing. In Flutter, this is the standard approach — and the widget framework is designed for it.
Why composition over inheritance
Flutter's widget tree is a hierarchy of composed widgets, not a deep class hierarchy. Each widget does one thing:
Paddingadds paddingCentercenters its childContainercombines decoration, padding, size, and alignment
Building your own widgets follows the same principle.
Inheritance: when it's wrong
// BAD: inheriting from a concrete widget to add behaviour
class RoundedCard extends Card {
const RoundedCard({super.key, required this.child});
final Widget child;
// Problem: you can't override Card's internal layout
// and you're locked into Card's implementation details
}
Composition: the Flutter way
// GOOD: compose existing widgets
class RoundedCard extends StatelessWidget {
const RoundedCard({super.key, required this.child, this.padding});
final Widget child;
final EdgeInsetsGeometry? padding;
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context).cardColor,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
padding: padding ?? const EdgeInsets.all(16),
child: child,
);
}
}
Component pattern: slots
Use named widget parameters to create flexible layouts with "slots":
class ProductCard extends StatelessWidget {
const ProductCard({
super.key,
required this.image,
required this.title,
required this.price,
this.badge, // Optional slot
this.actions, // Optional slot
});
final Widget image;
final Widget title;
final Widget price;
final Widget? badge;
final Widget? actions;
@override
Widget build(BuildContext context) {
return RoundedCard(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
image,
if (badge != null)
Positioned(top: 8, right: 8, child: badge!),
],
),
const SizedBox(height: 12),
title,
const SizedBox(height: 4),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
price,
if (actions != null) actions!,
],
),
],
),
);
}
}
// Usage is descriptive and flexible:
ProductCard(
image: CachedNetworkImage(imageUrl: product.imageUrl),
title: Text(product.name, style: Theme.of(context).textTheme.titleMedium),
price: PriceLabel(price: product.price, currency: 'USD'),
badge: product.isNew ? const NewBadge() : null,
actions: Row(
children: [
WishlistButton(productId: product.id),
const SizedBox(width: 8),
AddToCartButton(product: product),
],
),
)
Builder pattern for stateful composition
When a child widget needs to react to parent state, use a builder callback:
class CollapsibleSection extends StatefulWidget {
const CollapsibleSection({
super.key,
required this.title,
required this.builder,
this.initiallyExpanded = false,
});
final Widget title;
final WidgetBuilder builder; // Called only when expanded
final bool initiallyExpanded;
@override
State<CollapsibleSection> createState() => _CollapsibleSectionState();
}
class _CollapsibleSectionState extends State<CollapsibleSection> {
late bool _expanded = widget.initiallyExpanded;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
GestureDetector(
onTap: () => setState(() => _expanded = !_expanded),
child: Row(
children: [
Expanded(child: widget.title),
Icon(_expanded ? Icons.expand_less : Icons.expand_more),
],
),
),
if (_expanded) widget.builder(context),
],
);
}
}
// Usage:
CollapsibleSection(
title: const Text('Order Items'),
builder: (_) => OrderItemsList(items: order.items),
)
Mixin for shared behaviour
When multiple widgets need the same behaviour (not the same structure), use a mixin:
mixin LoadingStateMixin<T extends StatefulWidget> on State<T> {
bool isLoading = false;
Future<void> runWithLoading(Future<void> Function() operation) async {
setState(() => isLoading = true);
try {
await operation();
} finally {
if (mounted) setState(() => isLoading = false);
}
}
}
class _CreateOrderScreenState extends State<CreateOrderScreen>
with LoadingStateMixin {
Future<void> _submit() async {
await runWithLoading(() async {
await orderRepo.createOrder(orderRequest);
if (mounted) context.go('/orders');
});
}
}
When to use StatelessWidget vs StatefulWidget
StatelessWidget: no mutable state, receives data from parent
StatefulWidget: manages its own state (expanded, loading, cursor position)
HookWidget (flutter_hooks): functional stateful widgets, less boilerplate
Common pitfalls
Widget classes with too many parameters. A widget with 15 constructor parameters is trying to do too much. Split it into composed smaller widgets with fewer responsibilities.
Passing callbacks 3 levels deep. If a button at the bottom of a widget tree needs to call a function from a grandparent widget, use a state management solution (BLoC, Riverpod) instead of threading the callback through intermediate widgets.
Using InheritedWidget for non-theme data. InheritedWidget causes all descendants that read it to rebuild on every change. For app state, use Riverpod or BLoC instead.
Sign in to like, dislike, or report.