State Management Fundamentals

Provider: Read, Watch & Select

50 min Lesson 9 of 14

Understanding Provider Access Methods

Provider offers three distinct ways to access state from your widgets: context.watch, context.read, and context.select. Each method serves a different purpose and has unique performance characteristics. Choosing the right method is essential for building efficient, responsive Flutter applications.

Key Principle: The way you access a provider determines when and how often your widget rebuilds. Using the wrong access method is one of the most common sources of performance issues in Provider-based apps.

context.watch — Rebuild on Every Change

The context.watch<T>() method subscribes your widget to a provider and triggers a rebuild every time the provider’s value changes. This is the most commonly used access method and is ideal when your widget needs to display data that updates frequently.

Basic context.watch Usage

class CounterModel extends ChangeNotifier {
  int _count = 0;
  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

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

  @override
  Widget build(BuildContext context) {
    // Rebuilds every time CounterModel calls notifyListeners()
    final counter = context.watch<CounterModel>();

    return Text(
      'Count: \${counter.count}',
      style: const TextStyle(fontSize: 24),
    );
  }
}

When you call context.watch<CounterModel>(), the widget registers itself as a listener. Every time notifyListeners() is called on the CounterModel, the widget’s build method runs again, ensuring the UI stays in sync with the latest state.

Tip: You can also use Provider.of<T>(context) as an equivalent to context.watch<T>(). Both subscribe to changes and trigger rebuilds. The context.watch syntax is shorter and more modern.

context.read — One-Time Access, No Rebuild

The context.read<T>() method retrieves the provider’s current value without subscribing to changes. The widget will not rebuild when the provider’s value changes. This is perfect for calling methods on a provider (like triggering actions) without needing to observe its state.

Using context.read for Actions

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

  @override
  Widget build(BuildContext context) {
    // No watch needed here — we only call methods
    return ElevatedButton(
      onPressed: () {
        // Read the provider to call a method
        context.read<CounterModel>().increment();
      },
      child: const Text('Increment'),
    );
  }
}

In this example, the button only needs to trigger an action. It does not display any data from the provider, so there is no reason to subscribe to changes. Using context.read prevents unnecessary rebuilds of the button widget.

Warning: Never use context.read inside the build method to display data. If you read a value without watching it, your widget will show stale data because it will not rebuild when the value changes. Use context.watch or context.select for any value you display in the UI.

context.select — Rebuild on Specific Field Changes

The context.select<T, R>() method is the most powerful optimization tool in Provider. It lets you select a specific piece of a provider’s state, and the widget only rebuilds when that particular piece changes. This is crucial for complex models with many fields.

Selective Rebuilds with context.select

class UserModel extends ChangeNotifier {
  String _name = 'Edrees';
  String _email = 'edrees@example.com';
  int _loginCount = 0;

  String get name => _name;
  String get email => _email;
  int get loginCount => _loginCount;

  void updateName(String newName) {
    _name = newName;
    notifyListeners();
  }

  void updateEmail(String newEmail) {
    _email = newEmail;
    notifyListeners();
  }

  void recordLogin() {
    _loginCount++;
    notifyListeners();
  }
}

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

  @override
  Widget build(BuildContext context) {
    // Only rebuilds when the name changes
    // Does NOT rebuild when email or loginCount changes
    final name = context.select<UserModel, String>(
      (user) => user.name,
    );

    return Text('Hello, \$name');
  }
}

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

  @override
  Widget build(BuildContext context) {
    // Only rebuilds when the email changes
    final email = context.select<UserModel, String>(
      (user) => user.email,
    );

    return Text('Email: \$email');
  }
}

In the example above, UserNameDisplay only rebuilds when the name property changes. If recordLogin() is called and increments loginCount, UserNameDisplay is not rebuilt because the selected value (name) did not change. Provider uses the == operator to compare the previous and new selected values.

The Selector Widget

In addition to context.select, Provider offers a Selector widget that provides the same selective rebuild behavior in a widget-based syntax. This can be useful when you want more explicit control or when working with complex selection logic.

Using the Selector Widget

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

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Selector widget — only rebuilds when name changes
        Selector<UserModel, String>(
          selector: (context, user) => user.name,
          builder: (context, name, child) {
            return Text(
              'Name: \$name',
              style: const TextStyle(fontSize: 20),
            );
          },
        ),

        // Selector with a child that never rebuilds
        Selector<UserModel, int>(
          selector: (context, user) => user.loginCount,
          builder: (context, count, child) {
            return Row(
              children: [
                child!, // Static icon — never rebuilds
                Text('Logins: \$count'),
              ],
            );
          },
          child: const Icon(Icons.login), // Cached child
        ),
      ],
    );
  }
}
Tip: The Selector widget’s child parameter works like Consumer’s child — it is built once and reused across rebuilds. Use it for static parts of the subtree that do not depend on the selected value.

When to Use Each Method

Choosing the right access method is critical for both correctness and performance. Here is a clear guide:

Decision Guide

// 1. context.watch — Display data that changes
//    Use when: Your widget SHOWS provider data
//    Rebuilds: On EVERY notifyListeners() call
Widget build(BuildContext context) {
  final model = context.watch<MyModel>(); // Subscribes
  return Text(model.someValue);            // Displays data
}

// 2. context.read — Trigger actions
//    Use when: Your widget CALLS methods on a provider
//    Rebuilds: NEVER (no subscription)
Widget build(BuildContext context) {
  return ElevatedButton(
    onPressed: () => context.read<MyModel>().doSomething(),
    child: const Text('Action'),         // Static label
  );
}

// 3. context.select — Display specific data
//    Use when: Your widget shows PART of a provider's data
//    Rebuilds: ONLY when the selected value changes
Widget build(BuildContext context) {
  final title = context.select<MyModel, String>(
    (m) => m.title,
  );
  return Text(title);                      // Only this field
}

Performance Optimization with Select

The biggest performance gains come from using context.select in list items and frequently updating widgets. Consider a scenario where a list of products is displayed, but only the price updates in real time:

Optimized List with select

class ProductCatalog extends ChangeNotifier {
  final List<Product> _products = [];
  List<Product> get products => List.unmodifiable(_products);

  void updatePrice(int index, double newPrice) {
    _products[index] = _products[index].copyWith(price: newPrice);
    notifyListeners();
  }
}

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

  @override
  Widget build(BuildContext context) {
    // Watch the entire list length for additions/removals
    final productCount = context.select<ProductCatalog, int>(
      (catalog) => catalog.products.length,
    );

    return ListView.builder(
      itemCount: productCount,
      itemBuilder: (context, index) {
        return ProductTile(index: index);
      },
    );
  }
}

class ProductTile extends StatelessWidget {
  final int index;
  const ProductTile({super.key, required this.index});

  @override
  Widget build(BuildContext context) {
    // Each tile only rebuilds when ITS product changes
    final product = context.select<ProductCatalog, Product>(
      (catalog) => catalog.products[index],
    );

    return ListTile(
      title: Text(product.name),
      subtitle: Text('\$\${product.price.toStringAsFixed(2)}'),
      trailing: IconButton(
        icon: const Icon(Icons.edit),
        onPressed: () => context.read<ProductCatalog>().updatePrice(
          index, product.price + 1.0,
        ),
      ),
    );
  }
}

Common Mistakes

Understanding what not to do is just as important as knowing the correct patterns. Here are the most frequent mistakes developers make with Provider access methods:

Mistake 1: Using watch in Callbacks

Wrong vs Right: watch in Callbacks

// WRONG — context.watch inside a callback
Widget build(BuildContext context) {
  return ElevatedButton(
    onPressed: () {
      // This will throw an error at runtime!
      final model = context.watch<MyModel>();
      model.doSomething();
    },
    child: const Text('Click'),
  );
}

// CORRECT — use context.read in callbacks
Widget build(BuildContext context) {
  return ElevatedButton(
    onPressed: () {
      // read is designed for one-time access
      final model = context.read<MyModel>();
      model.doSomething();
    },
    child: const Text('Click'),
  );
}

Mistake 2: Using read in build for Display

Wrong vs Right: read in build

// WRONG — widget will show stale data
Widget build(BuildContext context) {
  final counter = context.read<CounterModel>();
  return Text('Count: \${counter.count}'); // Never updates!
}

// CORRECT — use watch to stay in sync
Widget build(BuildContext context) {
  final counter = context.watch<CounterModel>();
  return Text('Count: \${counter.count}'); // Updates on change
}

// BEST — use select if you only need one field
Widget build(BuildContext context) {
  final count = context.select<CounterModel, int>(
    (c) => c.count,
  );
  return Text('Count: \$count'); // Updates only when count changes
}
Warning: Using context.watch inside initState, dispose, or any asynchronous callback (like Future.then or Timer callbacks) will throw an error. Always use context.read outside of the build method.

Practical Example: Optimized Shopping Cart

Let’s build a complete example that demonstrates all three access methods working together in a shopping cart scenario:

Complete Shopping Cart Example

class CartModel extends ChangeNotifier {
  final List<CartItem> _items = [];
  double _taxRate = 0.15;

  List<CartItem> get items => List.unmodifiable(_items);
  int get itemCount => _items.length;
  double get taxRate => _taxRate;

  double get subtotal =>
      _items.fold(0, (sum, item) => sum + item.price * item.quantity);
  double get tax => subtotal * _taxRate;
  double get total => subtotal + tax;

  void addItem(CartItem item) {
    _items.add(item);
    notifyListeners();
  }

  void removeItem(int index) {
    _items.removeAt(index);
    notifyListeners();
  }

  void updateQuantity(int index, int quantity) {
    _items[index] = _items[index].copyWith(quantity: quantity);
    notifyListeners();
  }
}

// Badge only cares about item count
class CartBadge extends StatelessWidget {
  const CartBadge({super.key});

  @override
  Widget build(BuildContext context) {
    // select: only rebuilds when itemCount changes
    final count = context.select<CartModel, int>(
      (cart) => cart.itemCount,
    );

    return Badge(
      label: Text('\$count'),
      child: const Icon(Icons.shopping_cart),
    );
  }
}

// Total display only cares about the total
class CartTotalDisplay extends StatelessWidget {
  const CartTotalDisplay({super.key});

  @override
  Widget build(BuildContext context) {
    // select: only rebuilds when total changes
    final total = context.select<CartModel, double>(
      (cart) => cart.total,
    );

    return Text(
      'Total: \$\${total.toStringAsFixed(2)}',
      style: const TextStyle(
        fontSize: 24,
        fontWeight: FontWeight.bold,
      ),
    );
  }
}

// Checkout button only needs to trigger actions
class CheckoutButton extends StatelessWidget {
  const CheckoutButton({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        // read: one-time access to call a method
        final cart = context.read<CartModel>();
        processCheckout(cart.items, cart.total);
      },
      child: const Text('Checkout'),
    );
  }
}

// Cart page watches the full model for the item list
class CartPage extends StatelessWidget {
  const CartPage({super.key});

  @override
  Widget build(BuildContext context) {
    // watch: rebuilds on any cart change
    final cart = context.watch<CartModel>();

    return Column(
      children: [
        Expanded(
          child: ListView.builder(
            itemCount: cart.items.length,
            itemBuilder: (context, index) {
              final item = cart.items[index];
              return ListTile(
                title: Text(item.name),
                subtitle: Text('Qty: \${item.quantity}'),
                trailing: IconButton(
                  icon: const Icon(Icons.delete),
                  onPressed: () =>
                      context.read<CartModel>().removeItem(index),
                ),
              );
            },
          ),
        ),
        const CartTotalDisplay(),
        const CheckoutButton(),
      ],
    );
  }
}
Performance Summary: In this cart example, the badge only rebuilds when items are added or removed, the total display only rebuilds when the price changes, and the checkout button never rebuilds due to state changes. Only the cart page itself rebuilds on every change because it displays the full item list.