DEV Community

Cover image for Flutter Widget Design: Write Once, Reuse Everywhere
FARINU TAIWO
FARINU TAIWO

Posted on

Flutter Widget Design: Write Once, Reuse Everywhere

UIs in Flutter are built from widgets, and as an app grows the number of widgets in the codebase grows with it. Because of this, designing widgets well becomes essential. Good widgets should be declarative, focused, and responsible for doing one thing well so the UI layer stays simple. The way widgets are structured largely determines whether a codebase remains clean and scalable or gradually becomes difficult to maintain.

The underlying widget system is intentionally simple. Everything is a widget, widgets compose together, and the UI is rebuilt from state. Internally, widgets are lightweight configurations that describe the UI, while the render tree performs the actual layout and painting. This design makes rebuilding inexpensive, but it also requires developers to be intentional about component design. The real challenge is creating widgets that are truly reusable: components that accept only the data they need, render it, and emit events upward without depending on where they are used or which state management sits above them. Ultimately, well-designed Flutter widgets are independent, data-driven, event-based, and built for reusability.

Prefer Parameters Over Hardcoded Values

The most common mistake is embedding values directly in a widget. Colors, sizes, strings, and padding cannot adapt if they are hard coded."

// ❌ Don't do this
class PrimaryButton extends StatelessWidget {
  final String label;

  const PrimaryButton({super.key, required this.label});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      style: ElevatedButton.styleFrom(
        backgroundColor: Colors.blue,      // ❌ hardcoded colour
        minimumSize: const Size(double.infinity, 52), // ❌ hardcoded size
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(8),  // ❌ hardcoded radius
        ),
      ),
      onPressed: () {}, // ❌ no-op — caller can't respond
      child: Text(label),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This widget above is an example of a rigid button.
This button looks reusable, but it is not because every visual property is built in.

Now here's how to fix it. Every dimension, colour, and behaviour must flow in as a parameter:

class AppButton extends StatelessWidget {
  const AppButton({
    super.key,
    required this.label,
    required this.onPressed,
    this.isLoading = false,
    this.isDisabled = false,
    this.backgroundColor,
    this.textColor,
    this.borderRadius = 10.0,
    this.height = 52.0,
    this.width = double.infinity,
    this.icon,
  });

  final String label;
  final VoidCallback? onPressed;
  final bool isLoading;
  final bool isDisabled;
  final Color? backgroundColor;
  final Color? textColor;
  final double borderRadius;
  final double height;
  final double width;
  final Widget? icon;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final effectiveColor = backgroundColor ?? theme.colorScheme.primary;
    final isActive = !isDisabled && !isLoading;

    return AnimatedOpacity(
      duration: const Duration(milliseconds: 200),
      opacity: isActive ? 1.0 : 0.55,
      child: SizedBox(
        height: height,
        width: width,
        child: ElevatedButton(
          onPressed: isActive ? onPressed : null,
          style: ElevatedButton.styleFrom(
            backgroundColor: effectiveColor,
            foregroundColor: textColor ?? theme.colorScheme.onPrimary,
            shape: RoundedRectangleBorder(
              borderRadius: BorderRadius.circular(borderRadius),
            ),
          ),
          child: isLoading
              ? SizedBox(
                  width: 20,
                  height: 20,
                  child: CircularProgressIndicator(
                    strokeWidth: 2,
                    color: textColor ?? theme.colorScheme.onPrimary,
                  ),
                )
              : Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    if (icon != null) ...[icon!, const SizedBox(width: 8)],
                    Text(label),
                  ],
                ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Notice how backgroundColor falls back to theme.colorScheme.primary. When a caller doesn't pass a colour, you get the theme's primary. When they do, you get exactly what they passed. Defaults first, overrides second.

Isolate business logic with callbacks

A reusable widget must not know what happens when it is interacted with. It only knows that something happened and reports it upward. All logic lives in the parent, provider, or use case but never in the widget itself.

An example is a product card that includes action buttons such as Add to Cart, a favorite icon, and onTap functionality.

class ProductCard extends StatelessWidget {
  const ProductCard({
    super.key,
    required this.product,
    required this.onTap,
    required this.onAddToCart,
    this.onToggleWishlist,
    this.isWishlisted = false,
  });

  final ProductModel product;
  final VoidCallback onTap;
  final VoidCallback onAddToCart;
  final VoidCallback? onToggleWishlist;
  final bool isWishlisted;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Card(
        child: Column(
          children: [
            Stack(children: [
              ProductImage(url: product.imageUrl),
              if (onToggleWishlist != null)
                WishlistIcon(
                  isFilled: isWishlisted,
                  onTap: onToggleWishlist!,
                ),
            ]),
            ProductInfo(product: product),
            AppButton(
              label: 'Add to cart',
              onPressed: onAddToCart,
            ),
          ],
        ),
      ),
    );
  }
}

Enter fullscreen mode Exit fullscreen mode

Usage

ProductCard(
  product: product,
  isWishlisted: context.watch<WishlistProvider>().contains(product.id),
  onTap: () => context.push('/product/${product.id}'),
  onAddToCart: () => context.read<CartProvider>().add(product),
  onToggleWishlist: () => context.read<WishlistProvider>().toggle(product),
);
Enter fullscreen mode Exit fullscreen mode

Composition over inheritance

Flutter explicitly favours composition. Instead of subclassing a widget to add features, build small focused widgets and compose them. This keeps each piece testable, readable, and independently reusable.

Although the section header is a simple widget, it can be broken down into three separate widgets.

// Three small, composable widgets

class SectionHeader extends StatelessWidget {
  const SectionHeader({
    super.key,
    required this.title,
    this.action,
  });

  final String title;
  final Widget? action;

  @override
  Widget build(BuildContext context) {
    return Row(
      mainAxisAlignment: MainAxisAlignment.spaceBetween,
      children: [
        Text(title, style: Theme.of(context).textTheme.titleMedium),
        if (action != null) action!,
      ],
    );
  }
}

class SeeAllButton extends StatelessWidget {
  const SeeAllButton({super.key, required this.onTap});
  final VoidCallback onTap;

  @override
  Widget build(BuildContext context) {
    return GestureDetector(
      onTap: onTap,
      child: Text('See all', style: TextStyle(color: Theme.of(context).primaryColor)),
    );
  }
}

// Compose them freely at call sites
SectionHeader(
  title: 'Popular Restaurants',
  action: SeeAllButton(onTap: () => context.push('/restaurants')),
);
Enter fullscreen mode Exit fullscreen mode

You can notice that action is nullable because some section headers do not have an action button like See all.

Provide sensible defaults

A button that needs 12 parameters just to render is hard to use. Instead, use optional parameters with default values for the most common case. People using the widget should only have to provide what is different for their situation.

Every required parameter is friction. If a value has a sensible default, make it optional. Aim for your widget to be usable with one or two parameters in the most common case, while still allowing overrides for less common scenarios.

Let’s take, for example, an empty state widget.

class EmptyState extends StatelessWidget {
  const EmptyState({
    super.key,
    this.title = 'Nothing here yet',
    this.subtitle = 'Check back later.',
    this.icon = Icons.inbox_outlined,
    this.iconSize = 64.0,
    this.action,
  });

  final String title;
  final String subtitle;
  final IconData icon;
  final double iconSize;
  final Widget? action;

  @override
  Widget build(BuildContext context) { ... }
}

// Minimal usage, defaults handle the rest
const EmptyState()

// Context-specific override
EmptyState(
  title: 'No orders yet',
  subtitle: 'Your completed orders will appear here.',
  icon: Icons.receipt_long_outlined,
  action: AppButton(label: 'Browse restaurants', onPressed: onBrowse),
)
Enter fullscreen mode Exit fullscreen mode

Handling theming and responsive sizing

Hardcoded pixel values are the silent killer of cross-device consistency. Always pull colours from Theme.of(context) and sizes from a responsive utility (like flutter_screenutil) or define them via ThemeData extensions.

class AppChip extends StatelessWidget {
  const AppChip({
    super.key,
    required this.label,
    this.isSelected = false,
    this.onTap,
  });

  final String label;
  final bool isSelected;
  final VoidCallback? onTap;

  @override
  Widget build(BuildContext context) {
    final theme = Theme.of(context);
    final colorScheme = theme.colorScheme;

    return GestureDetector(
      onTap: onTap,
      child: AnimatedContainer(
        duration: const Duration(milliseconds: 180),
        padding: EdgeInsets.symmetric(
          horizontal: 14.w,  // responsive via screenutil
          vertical: 8.h,
        ),
        decoration: BoxDecoration(
          color: isSelected ? colorScheme.primary : colorScheme.surface,
          border: Border.all(
            color: isSelected ? colorScheme.primary : colorScheme.outline,
          ),
          borderRadius: BorderRadius.circular(100),
        ),
        child: Text(
          label,
          style: theme.textTheme.labelMedium?.copyWith(
            color: isSelected ? colorScheme.onPrimary : colorScheme.onSurface,
            fontWeight: isSelected ? FontWeight.w600 : FontWeight.w400,
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Common anti-patterns to avoid

Designing reusable widgets is not only about following best practices, it is also about avoiding patterns that quietly make a codebase harder to maintain. Many widget problems do not appear immediately, but over time they lead to rigid components, duplicated logic, and difficult refactoring.

Below are some common anti patterns that often appear in Flutter codebases as they grow.

Building God widgets

A widget that renders a product card, handles pagination, manages a search bar, and displays error states is not reusable. It is a screen masquerading as a component. Split it. Each widget should do one thing and do it well.

For example, a “god widget” might look like this:

class ProductWidget extends StatefulWidget {
  const ProductWidget({super.key});

  @override
  State<ProductWidget> createState() => _ProductWidgetState();
}

class _ProductWidgetState extends State<ProductWidget> {
  final TextEditingController searchController = TextEditingController();
  List<Product> products = [];
  bool isLoading = false;
  String? error;

  Future<void> fetchProducts({int page = 1}) async {
    setState(() => isLoading = true);

    try {
      final result = await Api.fetchProducts(
        query: searchController.text,
        page: page,
      );

      setState(() {
        products = result;
        error = null;
      });
    } catch (e) {
      error = "Failed to load products";
    }

    setState(() => isLoading = false);
  }

  @override
  Widget build(BuildContext context) {
    if (isLoading) {
      return const CircularProgressIndicator();
    }

    if (error != null) {
      return Text(error!);
    }

    return Column(
      children: [
        TextField(
          controller: searchController,
          decoration: const InputDecoration(
            hintText: "Search products",
          ),
          onSubmitted: (_) => fetchProducts(),
        ),
        Expanded(
          child: ListView.builder(
            itemCount: products.length,
            itemBuilder: (_, index) {
              final product = products[index];

              return Card(
                child: ListTile(
                  leading: Image.network(product.image),
                  title: Text(product.name),
                  subtitle: Text("\$${product.price}"),
                ),
              );
            },
          ),
        ),
        ElevatedButton(
          onPressed: () => fetchProducts(page: 2),
          child: const Text("Load more"),
        )
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

This widget is trying to do too many things at once:

  • Fetching data
  • Handling search input
  • Managing pagination
  • Showing loading and error states
  • Rendering product cards

Widgets that combine all of these responsibilities are difficult to maintain and reuse.

A better approach is to split responsibilities into smaller, focused widgets:

  • ProductSearchBar – handles user input for searching
  • ProductList – displays the list of products
  • ProductCard – renders a single product item
  • ProductErrorState – shows errors when data fails to load
  • ProductPagination – manages pagination controls

By giving each widget a single responsibility, they become easier to reuse independently. The resulting UI is also simpler to reason about, easier to test, and much more flexible to modify in the future.

Depending on a specific model type

A widget that accepts a RestaurantModel directly can't show a GroceryStoreModel even if the UI is identical. Prefer accepting primitives or a thin display-specific abstraction.

// Wrong! Only works for RestaurantModel
class VendorTile extends StatelessWidget {
  final RestaurantModel restaurant; // ❌
  ...
}

// Right! Use primitives
class VendorTile extends StatelessWidget {
  const VendorTile({
    required this.name,
    required this.imageUrl,
    required this.rating,
    this.badge,
  });
  final String name, imageUrl;
  final double rating;
  final String? badge;
  ...
}
Enter fullscreen mode Exit fullscreen mode

Defining child widgets as inline functions

Writing helper methods like Widget _buildHeader() and calling them inside build() still executes as part of the same build scope. They don't get an independent element and won't be skipped when irrelevant parts of the tree change."

// Wrong! Helper method — always runs with the parent
Widget build(BuildContext ctx) {
  return Column(children: [
    _buildHeader(),   // ❌ re-runs every build
    _buildBody(),
  ]);
}
Widget _buildHeader() => Text('Header');

// Right! Separate widget class — gets its own element
Widget build(BuildContext ctx) {
  return Column(children: [
    const _Header(),  // ✅ skipped if props unchanged
    const _Body(),
  ]);
}
class _Header extends StatelessWidget {
  const _Header();
  @override
  Widget build(BuildContext ctx) => Text('Header');
}
Enter fullscreen mode Exit fullscreen mode

Using boolean flags to switch between entirely different UIs

Parameters like isCompact, isGrid, isDetailed are a sign the widget is doing two or three separate jobs. Each boolean adds a hidden code path and exponentially increases mental overhead.

// ❌ Three different layouts hidden behind boolean flags
class ProductCard extends StatelessWidget {
  final bool isCompact;
  final bool isGrid;
  final bool isHorizontal;
  ...
}

// ✅ Three focused widgets, each doing one thing
class ProductCardCompact extends StatelessWidget { ... }
class ProductCardGrid    extends StatelessWidget { ... }
class ProductCardHorizontal extends StatelessWidget { ... }
Enter fullscreen mode Exit fullscreen mode

Not marking immutable constructors as const

If you leave out const in a widget constructor, Flutter cannot skip rebuilding it. If a widget can be const, always make it const. It makes your app faster.

// ❌ New instance on every parent rebuild
Column(children: [
  Icon(Icons.home),       // ❌ missing const
  SizedBox(height: 8),   // ❌ missing const
  Text('Home'),           // ❌ missing const
])

// ✅ Flutter skips it with no cost to rebuild
Column(children: [
  const Icon(Icons.home),
  const SizedBox(height: 8),
  const Text('Home'),
])
Enter fullscreen mode Exit fullscreen mode

Well-designed Flutter widgets are focused, reusable, and data-driven. Avoid hardcoding, god widgets, and hidden boolean logic. Let each widget do one thing and report events upward. By embracing composition, sensible defaults, and theming, you truly achieve “write once, reuse everywhere,” keeping your code clean, flexible, and future-proof.

Top comments (0)