Back to Blog
Flutter Development

Building Reusable Custom Widgets

Best practices for creating maintainable and reusable custom widgets in Flutter with practical examples.

J
Jubair Hossain
CEO & Founder of DevCenter
May 10, 2025
7 min read
Building Reusable Custom Widgets

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 ThemeData and InheritedWidget

Anatomy of a Good Custom Widget

Three traits separate a great widget from a brittle one:

  1. Single responsibility: one job, well done
  2. Configurable, not bloated: expose what callers actually need, hide internals
  3. Composable: returns a single root Widget that 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 IconButton or Semantics(button: true, ...)
  • Provide excludeFromSemantics for 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: missed const constructors 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

Tags

FlutterWidgetsUI Development

Share this article