Custom widgets are the building blocks of every great Flutter application. They let you encapsulate UI logic, enforce design consistency, and ship features faster across Android and iOS. In this guide we cover the patterns we use at scale to ship reusable widgets that hold up in production.
Why Reusable Widgets Matter
Mobile apps grow fast. A button copy-pasted into 30 screens becomes a maintenance nightmare the moment the design system changes. Reusable widgets give you a single source of truth for visuals, behavior, and accessibility — so a single edit propagates everywhere.
- Consistency: pixel-perfect UI across screens
- Speed: composable building blocks accelerate feature delivery
- Testability: isolated widgets are easier to unit and golden test
- Theming: one widget, many themes via
ThemeDataandInheritedWidget
Anatomy of a Good Custom Widget
Three traits separate a great widget from a brittle one:
- Single responsibility: one job, well done
- Configurable, not bloated: expose what callers actually need, hide internals
- Composable: returns a single root
Widgetthat plays well with others
StatelessWidget vs StatefulWidget
Use StatelessWidget when the widget is a pure function of its inputs. Promote to StatefulWidget only when you need internal state, animation tickers, or controllers.
A Reusable Primary Button
class PrimaryButton extends StatelessWidget {
final String label;
final VoidCallback? onPressed;
final IconData? icon;
final bool isLoading;
const PrimaryButton({
super.key,
required this.label,
this.onPressed,
this.icon,
this.isLoading = false,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return ElevatedButton(
onPressed: isLoading ? null : onPressed,
style: ElevatedButton.styleFrom(
backgroundColor: theme.colorScheme.primary,
foregroundColor: theme.colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
),
child: isLoading
? const SizedBox(
width: 18, height: 18,
child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white),
)
: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null) ...[
Icon(icon, size: 18),
const SizedBox(width: 8),
],
Text(label),
],
),
);
}
}
Composition Over Inheritance
Flutter rewards composition. Instead of subclassing, wrap and combine. A complex card UI becomes simple when broken into CardShell, CardHeader, and CardBody primitives.
class ProductCard extends StatelessWidget {
final Product product;
const ProductCard({super.key, required this.product});
@override
Widget build(BuildContext context) {
return CardShell(
onTap: () => context.go('/product/${product.id}'),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ProductImage(url: product.imageUrl),
const SizedBox(height: 12),
ProductTitle(text: product.name),
ProductPrice(amount: product.price),
],
),
);
}
}
Theming and Design Tokens
Hard-coded colors and font sizes kill reusability. Pull every visual value from Theme.of(context) or a custom InheritedWidget exposing your design tokens.
extension AppColors on ThemeData {
Color get success => brightness == Brightness.light
? const Color(0xFF16A34A)
: const Color(0xFF22C55E);
}
// Usage
Container(color: Theme.of(context).success);
Accessibility from Day One
Every reusable widget should ship with semantics built in. Use Semantics, meaningful tooltip values, and ensure tap targets are at least 48x48 logical pixels.
- Wrap interactive icons in
IconButtonorSemantics(button: true, ...) - Provide
excludeFromSemanticsfor purely decorative children - Test with TalkBack on Android and VoiceOver on iOS before shipping
Testing Custom Widgets
Reusable widgets earn their keep through tests. Use flutter_test for behavior and golden_toolkit for visual regressions.
testWidgets('PrimaryButton shows loader when isLoading', (tester) async {
await tester.pumpWidget(MaterialApp(
home: Scaffold(body: PrimaryButton(label: 'Save', isLoading: true)),
));
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Save'), findsNothing);
});
Common Pitfalls
- Leaking controllers: always dispose
AnimationController,TextEditingController,ScrollController - Over-parameterization: 15 boolean flags is a smell — split into multiple widgets
- Skipping
const: missedconstconstructors trigger needless rebuilds - Tight coupling: a widget that imports your repository layer is no longer reusable
Conclusion
Reusable widgets are an investment that compounds with every screen you build. Keep them small, configurable, themed, and tested. Your future self — and the rest of your Flutter team — will thank you.
Key takeaways:
- Compose, don't inherit
- Drive visuals from
ThemeData - Bake accessibility in from the start
- Lock behavior with widget and golden tests