Skip to content
ExponaBLOG
DESIGN PATTERNS IN PRACTICE · DAY 095 / 100

Patterns in Flutter: a practical Design Patterns guide

Flutter is built on familiar patterns.

Expona Admin8 min read

Introduction

After nearly a decade of building cross-platform mobile apps, I've noticed that Patterns in Flutter aren't framework-specific inventions—they're industry-standard design patterns adapted brilliantly to Flutter's reactive paradigm. When you understand how Flutter applies Composite, Observer, Builder, and dependency injection patterns, you stop fighting the framework and start shipping features faster.

Flutter's architecture is built on familiar patterns that you've likely encountered in web or backend development. The difference is how elegantly Flutter bakes them into the widget system, making them feel natural rather than bolted-on. Over the next 95 days of this series, we've explored patterns abstractly; today, we make them concrete within Flutter's ecosystem.

Composite: The Widget Tree

The Composite pattern organizes objects into tree structures to represent part-whole hierarchies. In Flutter, every screen is a tree of widgets, and understanding this structure is fundamental to effective Flutter development.

When you build a shopping cart screen, you're composing smaller widgets into larger ones. A CartScreen contains a ListView, which contains multiple CartItem widgets, each containing a product image, name, quantity picker, and price. The beauty is that each level of composition handles its own responsibility without needing to know about the entire tree.

typescript
class CartScreen extends StatelessWidget {
  final List<Product> products;

  const CartScreen({required this.products});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Shopping Cart')),
      body: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          return CartItem(
            product: products[index],
            onQuantityChanged: (quantity) {
              // Handle quantity change
            },
          );
        },
      ),
    );
  }
}

class CartItem extends StatelessWidget {
  final Product product;
  final Function(int) onQuantityChanged;

  const CartItem({
    required this.product,
    required this.onQuantityChanged,
  });

  @override
  Widget build(BuildContext context) {
    return Card(
      child: Row(
        children: [
          Image.network(product.imageUrl, width: 80),
          Expanded(
            child: Column(
              crossAxisAlignment: CrossAxisAlignment.start,
              children: [
                Text(product.name),
                Text('\$${product.price}'),
              ],
            ),
          ),
          QuantityPicker(
            initial: product.quantity,
            onChanged: onQuantityChanged,
          ),
        ],
      ),
    );
  }
}

The Composite pattern here means each widget is both a container and a leaf in the tree. A Row can contain other Rows. A Column can contain Columns. This recursive composability is what makes Flutter's declarative model powerful—you build complex UIs by nesting simple, focused widgets.

The key insight: think in terms of what each widget owns and is responsible for rendering. Don't let a single widget become a god object that knows too much about its descendants.

Observer: ChangeNotifier and Streams

The Observer pattern notifies multiple objects about state changes. In Flutter, you have two primary tools: ChangeNotifier with setState, and reactive streams with StreamBuilder.

Let me walk through a real scenario from a ride-hailing app I built. When a user requests a ride, we need to update the map, the driver details, the ETA, and the payment summary simultaneously. Without an Observer pattern, we'd manually trigger rebuilds for each piece—error-prone and fragile.

typescript
class RideRequestNotifier extends ChangeNotifier {
  Ride? _currentRide;
  String _status = 'idle'; // idle, requesting, matched, in-transit

  Ride? get currentRide => _currentRide;
  String get status => _status;

  Future<void> requestRide(Location pickupLocation, Location dropoffLocation) async {
    _status = 'requesting';
    notifyListeners();

    try {
      final response = await _rideService.requestRide(
        pickup: pickupLocation,
        dropoff: dropoffLocation,
      );

      _currentRide = response;
      _status = 'matched';
      notifyListeners();
    } catch (e) {
      _status = 'error';
      notifyListeners();
      rethrow;
    }
  }

  void cancelRide() {
    _rideService.cancelRide(_currentRide!.id);
    _currentRide = null;
    _status = 'idle';
    notifyListeners();
  }
}

// In your widget tree, listeners automatically rebuild
class RideStatusWidget extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final rideNotifier = ref.watch(rideRequestProvider);

    return Column(
      children: [
        Text('Status: ${rideNotifier.status}'),
        if (rideNotifier.currentRide != null)
          RideDetailsCard(ride: rideNotifier.currentRide!),
      ],
    );
  }
}

The Observer pattern decouples state from presentation. Multiple widgets can listen to the same ChangeNotifier or stream without knowing about each other. When the ride status changes, notifyListeners() triggers rebuilds for all observers—the map updates, the ETA updates, the payment info updates, all without explicit coordination.

Streams offer similar benefits with more control over how changes propagate. For continuous data like GPS coordinates or real-time chat messages, streams are often cleaner than ChangeNotifier.

Builder: Builder Widgets Everywhere

The Builder pattern separates construction from representation. In Flutter, builder widgets like Builder, StreamBuilder, FutureBuilder, and custom builder methods are everywhere because they solve a specific problem: how do you get access to a narrower scope of context?

I learned this the hard way in an e-commerce app. I tried to show a snackbar from inside a nested widget, but the Scaffold was higher in the tree and I couldn't access it. The solution: wrap the section in a Builder widget to get a new context.

typescript
class ProductDetailScreen extends StatelessWidget {
  final String productId;

  const ProductDetailScreen({required this.productId});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Product')),
      body: FutureBuilder<Product>(
        future: productService.getProduct(productId),
        builder: (context, snapshot) {
          if (snapshot.connectionState == ConnectionState.waiting) {
            return const Center(child: CircularProgressIndicator());
          }

          if (snapshot.hasError) {
            return Center(child: Text('Error: ${snapshot.error}'));
          }

          final product = snapshot.data!;

          return ListView(
            children: [
              ProductImage(url: product.imageUrl),
              ProductInfo(product: product),
              Builder(
                builder: (innerContext) {
                  return ElevatedButton(
                    onPressed: () {
                      ScaffoldMessenger.of(innerContext).showSnackBar(
                        const SnackBar(content: Text('Added to cart')),
                      );
                    },
                    child: const Text('Add to Cart'),
                  );
                },
              ),
            ],
          );
        },
      ),
    );
  }
}

The FutureBuilder is a builder widget that handles async data. It takes a future and a builder function, letting you construct different widgets based on connection state and data availability. This encapsulation means you're not writing boilerplate state management for every async operation.

Custom builder widgets follow the same pattern. If you're building a complex dropdown menu or a modal dialog, defining a builder method or a builder widget keeps the logic isolated and testable.

Provider: Dependency Injection for Widgets

Dependency injection ensures that widgets and services aren't tightly coupled to their dependencies. The Provider package is Flutter's idiomatic approach, and it integrates beautifully with the Observer pattern.

In a trading platform I worked on, different screens needed access to the same service—the market data service. Instead of instantiating it in each screen, I provided it at the root level:

typescript
void main() {
  runApp(
    MultiProvider(
      providers: [
        Provider<MarketDataService>(
          create: (_) => MarketDataService(),
        ),
        ChangeNotifierProvider<PortfolioNotifier>(
          create: (context) => PortfolioNotifier(
            marketDataService: context.read<MarketDataService>(),
          ),
        ),
        StreamProvider<List<Quote>>(
          create: (context) => context.read<MarketDataService>().quoteStream(),
          initialData: const [],
        ),
      ],
      child: const MyApp(),
    ),
  );
}

class PortfolioScreen extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // Watch returns the notifier; if it changes, the widget rebuilds
    final portfolio = ref.watch(portfolioNotifierProvider);

    // Read gets a one-time value without rebuilding
    final marketService = ref.read(marketDataServiceProvider);

    return Column(
      children: [
        Text('Portfolio Value: \$${portfolio.totalValue}'),
        Text('YTD Return: ${portfolio.ytdReturn}%'),
      ],
    );
  }
}

Provider handles both lazy instantiation and lifecycle management. Services are created when first needed, cached, and disposed when no longer referenced. This is crucial for resource-heavy services like database connections or WebSocket connections.

The pattern separates concerns cleanly. Screens don't care how to construct services; they just request them. Services don't know about the UI. Testing becomes straightforward because you can provide mock services in tests.

Common Mistakes

  • Overcomposing widgets. Every little thing doesn't need to be its own widget. Extract widgets when they have reusable logic or when a widget becomes too complex (over 200 lines is a rough heuristic). Premature abstraction adds cognitive overhead.
  • Rebuilding too frequently. Not using const constructors or proper memoization can cause unnecessary rebuilds. Profile with DevTools to find culprits. A single expensive rebuild in a frequently-updated list can tank performance.
  • Misusing BuildContext scope. Accessing Scaffold.of(context) or Navigator.of(context) from the wrong context level causes runtime errors. When in doubt, wrap in a Builder to get a narrower scope.
  • Mixing state management approaches. Using setState, ChangeNotifier, and streams in the same app without clear boundaries creates confusion. Pick one pattern for business logic; use providers consistently.
  • Ignoring disposal in ChangeNotifier. If you create a ChangeNotifier manually instead of via Provider, forgetting to call dispose() on the controller leaks listeners. Always pair creation with cleanup.

Key Takeaways

  • Flutter's widget tree is the Composite pattern in action. Compose small, focused widgets into larger structures; each level owns its rendering responsibility.
  • ChangeNotifier and streams implement the Observer pattern. Multiple widgets listen to a single source of truth, decoupling state from UI.
  • Builder widgets like StreamBuilder, FutureBuilder, and Builder manage scope and async transitions. Use them to handle loading states and context issues elegantly.
  • Provider package brings dependency injection and lifecycle management to Flutter. Define dependencies at the root, inject them where needed, and let Provider handle instantiation and disposal.
  • These patterns aren't Flutter innovations—they're proven industry patterns adapted to Flutter's declarative, reactive model. Recognizing them helps you write maintainable, testable code faster.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles