Flutter state managemnt

Flutter State Management: setState vs Provider (With a Real-World Shopping Cart Example)

Quick Answer

What is state management in Flutter? State management is how a Flutter app stores, updates, and shares data (like a shopping cart, login status, or theme) so the user interface always reflects the latest values.

What is the difference between setState and Provider? setState rebuilds a single widget when its own local data changes. Provider allows multiple widgets across the app to share and react to the same data through a ChangeNotifier.

When should you use each? Use setState for small, temporary UI state scoped to one widget (a toggle, text field, or tab index). Use Provider for shared or app-level state (cart, user session, settings, or API data).

In this guide, you will build a real shopping cart app that uses both approaches together effectively.

Lesson 14: State Management in Flutter 2: Provider vs BLoC vs GetX (Complete Guide)

1. What Is State in Flutter?

In Flutter, state is any data that can change while the app is running and affects what the user sees on screen. Everything visible is a widget, and widgets are rebuilt whenever state changes.

Flutter developers generally categorize state into two types:

  • Ephemeral (local) state: Data that belongs strictly to a single widget, such as whether a heart icon is selected, an animation is playing, or which tab is active.
  • App (shared) state: Data needed by multiple screens or components across the application, such as user authentication tokens, cart items, theme preferences, or fetched catalog items.

Choosing the right tool for each type is fundamental to architecting performant, maintainable Flutter apps.

2. What Is setState and How Does It Work?

setState() is the built-in method provided by Flutter’s State<T> class for StatefulWidget. When invoked, it schedules a build phase for that specific widget subtree, causing build() to execute again with updated values.

Basic setState Example

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

  @override
  State<CounterWidget> createState() => _CounterWidgetState();
}

class _CounterWidgetState extends State<CounterWidget> {
  int _count = 0;

  void _increment() {
    setState(() {
      _count++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: _increment,
      child: Text('Clicked $_count times'),
    );
  }
}

Why it works well here: The counter state is isolated to this single widget. No other component in the tree requires access to _count.

Advantages of setState

  • Requires zero third-party dependencies.
  • Very low barrier to entry.
  • Ideal for self-contained, isolated UI behavior.

Limitations of setState

  • State is scoped strictly to the host widget and cannot easily be shared upward or sideways.
  • Passing data across layers (“prop drilling”) creates high coupling and boilerplate.
  • Mixes business/domain logic directly into presentation code.
  • Rebuilds the entire widget subtree instead of surgical, targeted updates.

3. When setState Is Not Enough

Consider a standard online store. The catalog screen contains an “Add to Cart” button, the top navigation bar holds a badge with total item counts, and a separate checkout screen calculates the grand total.

Three separate components across different screen routes require synchronous access to the exact same dataset. Managing this with setState requires lifting the state to the nearest common ancestor and passing variables and callbacks downward through every single widget constructor. This pattern is error-prone, fragile, and difficult to test.

This is where decoupled state management with Provider comes in.

4. What Is Provider in Flutter?

Provider is an officially recommended, community-standard abstraction layer built over Flutter’s native InheritedWidget. It provides a structured dependency injection and reactive listening framework without constructor drilling.

The architecture relies on three primary building blocks:

  1. ChangeNotifier: A foundation class that encapsulates business data and calls notifyListeners() when mutations occur.
  2. ChangeNotifierProvider: A widget that instantiates, provides, and disposes of a ChangeNotifier throughout its subtree.
  3. Consumers / Readers: Widgets that read and observe state using context.watch<T>(), context.read<T>(), Consumer<T>, or Selector<T, R>.
Data Flow: User interaction → Model method updates data → notifyListeners() called → Only subscribed listeners rebuild.

5. Real-Time Use Case: An E-Commerce Shopping Cart

We will implement a small application demonstrating both paradigms working together:

  • Product List Screen: Displays an inventory catalog.
  • Favorite (Heart) Toggle: Isolated to each product card — implemented via setState.
  • Add to Cart & Cart Badge: Shared across the top bar and inventory — implemented via Provider.
  • Cart Screen: Quantity adjustments and live subtotal calculations — implemented via Provider.

Project Setup

Add provider to your pubspec.yaml file:

dependencies:
  flutter:
    sdk: flutter
  provider: ^6.1.2

Suggested Project Structure

flutter state management

6. Step-by-Step Implementation

Step 1: Create the Models

// models/product.dart
class Product {
  final String id;
  final String name;
  final double price;

  const Product({
    required this.id,
    required this.name,
    required this.price,
  });
}


// models/cart_item.dart
import 'product.dart';

class CartItem {
  final Product product;
  int quantity;

  CartItem({required this.product, this.quantity = 1});

  double get subtotal => product.price * quantity;
}

Step 2: Create the CartProvider (ChangeNotifier)

// providers/cart_provider.dart

import 'dart:collection';
import 'package:flutter/foundation.dart';

import '../models/cart_item.dart';
import '../models/product.dart';




class CartProvider extends ChangeNotifier {
  final Map<String, CartItem> _items = {};

  UnmodifiableListView<CartItem> get items =>
      UnmodifiableListView(_items.values);

  int get totalCount =>
      _items.values.fold(0, (sum, item) => sum + item.quantity);

  double get totalPrice =>
      _items.values.fold(0.0, (sum, item) => sum + item.subtotal);

  bool get isEmpty => _items.isEmpty;

  void add(Product product) {
    if (_items.containsKey(product.id)) {
      _items[product.id]!.quantity++;
    } else {
      _items[product.id] = CartItem(product: product);
    }
    notifyListeners();
  }

  void decrease(String productId) {
    final item = _items[productId];
    if (item == null) return;
    if (item.quantity > 1) {
      item.quantity--;
    } else {
      _items.remove(productId);
    }
    notifyListeners();
  }

  void remove(String productId) {
    _items.remove(productId);
    notifyListeners();
  }

  void clear() {
    _items.clear();
    notifyListeners();
  }
}

Step 3: Register Provider at the Root Level

// main.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'providers/cart_provider.dart';
import 'screens/product_list_screen.dart';

void main() {
  runApp(
    ChangeNotifierProvider(
      create: (_) => CartProvider(),
      child: const MyApp(),
    ),
  );
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flutter Store',
      theme: ThemeData(useMaterial3: true, colorSchemeSeed: Colors.indigo),
      home: const ProductListScreen(),
    );
  }
}

Step 4: Product Card (setState for Local State)

// widgets/product_card.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';

import '../models/product.dart';
import '../providers/cart_provider.dart';

class ProductCard extends StatefulWidget {
  final Product product;
  const ProductCard({super.key, required this.product});

  @override
  State<ProductCard> createState() => _ProductCardState();
}

class _ProductCardState extends State<ProductCard> {
  bool _isFavorite = false; // Ephemeral local state

  @override
  Widget build(BuildContext context) {
    return Card(
      child: ListTile(
        title: Text(widget.product.name),
        subtitle: Text('USD ${widget.product.price.toStringAsFixed(2)}'),
        leading: IconButton(
          icon: Icon(
            _isFavorite ? Icons.favorite : Icons.favorite_border,
            color: _isFavorite ? Colors.red : null,
          ),
          onPressed: () {
            setState(() => _isFavorite = !_isFavorite);
          },
        ),
        trailing: FilledButton(
          onPressed: () {
            // Read without subscribing: trigger action only
            context.read<CartProvider>().add(widget.product);
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('${widget.product.name} added to cart')),
            );
          },
          child: const Text('Add'),
        ),
      ),
    );
  }
}

Step 5: Cart Badge with Selective Rebuilding

// widgets/cart_badge.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/cart_provider.dart';
import '../screens/cart_screen.dart';

class CartBadge extends StatelessWidget {
  const CartBadge({super.key});

  @override
  Widget build(BuildContext context) {
    return Selector<CartProvider, int>(
      selector: (_, cart) => cart.totalCount,
      builder: (context, count, child) {
        return IconButton(
          icon: Badge(
            isLabelVisible: count > 0,
            label: Text('$count'),
            child: const Icon(Icons.shopping_cart),
          ),
          onPressed: () {
            Navigator.of(context).push(
              MaterialPageRoute(builder: (_) => const CartScreen()),
            );
          },
        );
      },
    );
  }
}

Step 6: Product Catalog Screen

// screens/product_list_screen.dart
import 'package:flutter/material.dart';

import '../models/product.dart';
import '../widgets/product_card.dart';
import '../widgets/cart_badge.dart';

class ProductListScreen extends StatelessWidget {
  const ProductListScreen({super.key});

  static const _products = [
    Product(id: 'p1', name: 'Wireless Headphones', price: 59.99),
    Product(id: 'p2', name: 'Mechanical Keyboard', price: 89.50),
    Product(id: 'p3', name: 'USB-C Hub', price: 34.00),
    Product(id: 'p4', name: 'Laptop Stand', price: 27.25),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Flutter Store'),
        actions: const [CartBadge()],
      ),
      body: ListView(
        padding: const EdgeInsets.all(12),
        children: [
          for (final p in _products) ProductCard(product: p),
        ],
      ),
    );
  }
}

Step 7: Cart Detail Screen with Dynamic Totals

// screens/cart_screen.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/cart_provider.dart';

class CartScreen extends StatelessWidget {
  const CartScreen({super.key});

  @override
  Widget build(BuildContext context) {
    final cart = context.watch<CartProvider>();

    return Scaffold(
      appBar: AppBar(title: const Text('Your Cart')),
      body: cart.isEmpty
          ? const Center(child: Text('Your cart is empty'))
          : Column(
        children: [
          Expanded(
            child: ListView.builder(
              itemCount: cart.items.length,
              itemBuilder: (context, index) {
                final item = cart.items[index];
                return ListTile(
                  title: Text(item.product.name),
                  subtitle: Text(
                    'USD ${item.subtotal.toStringAsFixed(2)}',
                  ),
                  trailing: Row(
                    mainAxisSize: MainAxisSize.min,
                    children: [
                      IconButton(
                        icon: const Icon(Icons.remove_circle_outline),
                        onPressed: () => context
                            .read<CartProvider>()
                            .decrease(item.product.id),
                      ),
                      Text('${item.quantity}'),
                      IconButton(
                        icon: const Icon(Icons.add_circle_outline),
                        onPressed: () => context
                            .read<CartProvider>()
                            .add(item.product),
                      ),
                    ],
                  ),
                );
              },
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text(
                  'Total: USD ${cart.totalPrice.toStringAsFixed(2)}',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                FilledButton(
                  onPressed: () => context.read<CartProvider>().clear(),
                  child: const Text('Clear'),
                ),
              ],
            ),
          ),
        ],
      ),
    );
  }
}

7. Watch vs. Read vs. Selector: Which One Should You Use?

Method Rebuilds on Change? Primary Use Case
context.watch<T>() Yes Subscribing to and displaying values directly in build().
context.read<T>() No Invoking methods inside callback handlers (e.g., onPressed).
Selector<T, R> Only when selected slice changes High-performance rendering (e.g., watching only item counts).
Consumer<T> Yes (builder scope only) Limiting rebuilds to a granular subtree of a large widget tree.

8. setState vs Provider: Side-by-Side Comparison

Feature setState Provider
Scope Single widget App-wide or target subtrees
Setup Overhead None Low (single package)
Cross-Screen Sharing Complex (requires lifting state) Native and straightforward
Separation of Concerns Low (UI mixed with logic) High (ChangeNotifier isolation)
Testability Requires widget integration tests Unit-testable purely in Dart
Rebuild Granularity Rebuilds entire widget Granular (via Selector / Consumer)
Recommended For Toggles, local text fields, tabs Carts, Auth, User sessions, Themes

9. Best Practices for Flutter State Management

  1. Scope locally: Keep state as low in the tree as possible. Never place simple form field states in global singletons.
  2. Always call notifyListeners(): Failing to invoke this after modifying properties results in stale UIs.
  3. Expose unmodifiable collections: Use UnmodifiableListView to ensure exterior widgets cannot bypass provider methods.
  4. Maintain business logic in the Provider: Formatting, subtotal calculations, and network calls belong inside notifiers, not widgets.
  5. Separate read from watch: Use context.read in button actions and context.watch solely for rendering.
  6. Clean up resources: If notifiers allocate controllers, streams, or listeners, override dispose() to release them.
  7. Defend against unmounted setState calls: Always add if (!mounted) return; following asynchronous gaps.

10. Common Mistakes (and Fixes)

  • ProviderNotFoundException: Occurs when accessing a provider above where it was injected. Relocate your ChangeNotifierProvider above MaterialApp or the active navigator route.
  • UI Not Updating: Typically caused by missing notifyListeners() or mutating object properties directly without notifying the engine.
  • Using watch inside event handlers: Calling context.watch inside onPressed will trigger exceptions or unintended rebuild cycles. Switch to context.read.
  • Instantiating Providers Inside build(): Never write create: (_) => CartProvider() inside a fluctuating build() method; instantiate it once at an upper boundary.

11. Provider vs Riverpod vs Bloc: Where Does Provider Fit?

Provider remains an accessible, production-proven foundation for small-to-medium Flutter applications. As architectural complexity scales, consider these alternatives:

  • Riverpod: Built by the creator of Provider. It decouples state from the widget tree entirely, eliminates BuildContext dependencies, ensures compile-time safety, and handles asynchronous state with ease.
  • Bloc / Cubit: Implements a strict event-driven state machine architecture well suited for distributed teams, auditability, and deterministic unit testing.

12. Frequently Asked Questions

Can I use setState and Provider together?

Yes. Combining them is idiomatic Flutter. Use setState for transient UI changes (like an input toggle) and Provider for shared business data.

Does the provider degrade Flutter app performance?

No. Provider adds minimal overhead over standard InheritedWidgets. Suboptimal performance typically stems from rebuilding unnecessarily broad subtrees, which is mitigated using Selector or Consumer.

How do I persist cart state between app restarts?

Hydrate and serialize your models using a persistence layer like shared_preferences, SQLite (sqflite), or Hive directly within your CartProvider class upon initialisation and change operations.

13. Conclusion

Effective Flutter state management comes down to selecting the correct scope for your data:

  • Use setState for transient, widget-isolated UI behavior.
  • Use Provider with ChangeNotifier for shared domain models, user sessions, and shopping carts.
  • Apply watch, read, and Selector purposefully to safeguard app performance.

Lesson 14: State Management in Flutter 2: Provider vs BLoC vs GetX (Complete Guide)