Simplifying State Management in Flutter Using ValueNotifier and ValueListenableBuilder

Temitope AjiboyeTemitope Ajiboye
17 min read

State management is a crucial aspect of building Flutter applications.

In this article, we'll explore how to use ValueNotifier and ValueListenableBuilder for effective state management, from simple use cases to complex scenarios.

We'll build an e-commerce app to demonstrate these concepts in action.

Basic Concepts

ValueNotifier

ValueNotifier is a simple way to wrap a value and notify listeners when that value changes. It's part of Flutter's foundation library.

final counter = ValueNotifier<int>(0);

In this example, we create a ValueNotifier that holds an integer value, initially set to 0.

ValueListenableBuilder

ValueListenableBuilder is a widget that rebuilds its child when the value in a ValueNotifier changes.

ValueListenableBuilder<int>(
  valueListenable: counter,
  builder: (context, value, child) {
    return Text('Count: $value');
  },
)

You might ask, what is the relationship between ValueNotifier and ValueListenableBuilder?

ValueNotifier is a way to wrap a value that can change over time. It's part of the data/state management side of your application.
In the example above, the counter is a ValueNotifier that holds an integer value. You can change this value like this:

counter.value = 1;

However, just changing the value doesn't automatically update your UI. This is where ValueListenableBuilder comes in. It's a widget that listens to a ValueNotifier and rebuilds its child whenever the notifier's value changes. In the example above, the builder listens to the counter notifier to rebuild it’s child when the value changes.

The ValueListenableBuilder is the bridge between your data (ValueNotifier) and your UI. It's responsible for:

  • Listening to changes in the ValueNotifier

  • Rebuilding the widget tree when the value changes

  • Providing the current value to its builder function

Without ValueListenableBuilder:

  • Your UI wouldn't know when to update in response to changes in the ValueNotifier.

  • You'd have to manually trigger rebuilds of your widget tree every time the value changes, which would be inefficient and error-prone.

Combining them together:

class CounterPage extends StatelessWidget {
  final counter = ValueNotifier<int>(0);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: Center(
        child: ValueListenableBuilder<int>(
          valueListenable: counter,
          builder: (context, value, child) {
            return Text('Count: $value');
          },
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => counter.value++,
        child: Icon(Icons.add),
      ),
    );
  }
}

In this example:

  • The ValueNotifier (counter) holds the state.

  • The ValueListenableBuilder listens for changes to counter and updates the Text widget.

  • When the FloatingActionButton is pressed, it increments counter.value.

  • The ValueListenableBuilder detects this change and rebuilds the Text widget with the new value.

Without the ValueListenableBuilder, the Text widget wouldn't update when counter.value changes.

Now that we have got the basics out of the way, let’s deep dive some advanced concepts.

MultiValueListenableBuilder

When dealing with multiple ValueNotifiers, we can create a custom MultiValueListenableBuilder to simplify our code:

class MultiValueListenableBuilder extends StatelessWidget {
  final List<ValueListenable> valueListenables;
  final Widget Function(BuildContext context, List<dynamic> values, Widget? child) builder;
  final Widget? child;

  const MultiValueListenableBuilder({
    Key? key,
    required this.valueListenables,
    required this.builder,
    this.child,
  }) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder<dynamic>(
      valueListenable: valueListenables[0],
      builder: (context, value, _) {
        return _buildNested(context, [value], 1);
      },
    );
  }

  Widget _buildNested(BuildContext context, List<dynamic> values, int index) {
    if (index >= valueListenables.length) {
      return builder(context, values, child);
    }
    return ValueListenableBuilder<dynamic>(
      valueListenable: valueListenables[index],
      builder: (context, value, _) {
        return _buildNested(context, [...values, value], index + 1);
      },
    );
  }
}

Let's break down this MultiValueListenableBuilder class:

This class is designed to listen to multiple ValueNotifiers at once and rebuild a widget when any of them change.

It's a StatelessWidget, meaning it doesn't maintain its own state. It takes a list of ValueListenables, a builder function, and an optional child widget. It starts with the first ValueListenable. It then creates nested ValueListenableBuilders for each subsequent ValueListenable. At the innermost level, when all values are collected, it calls the provided builder function. If any ValueListenable changes, it triggers a rebuild of its corresponding ValueListenableBuilder and all inner ones. This creates a widget that rebuilds whenever any of the provided ValueListenables change. The builder function receives a list of all current values, allowing you to build your UI based on all of them.

In essence, this class is a more flexible version of the standard ValueListenableBuilder, allowing you to react to changes in multiple values at once, which is often needed in more complex state management scenarios.

Listenable.merge

An alternative to having a custom MultiValueListenableBuilder is to use the Listenable.merge constructor: https://api.flutter.dev/flutter/foundation/Listenable/Listenable.merge.html
With this, it returns a Listenable that triggers when any of the given Listenables themselves trigger.

E-Commerce App Implementation

Let's build an e-commerce app using ValueNotifier and ValueListenableBuilder. We'll implement product listing, a shopping cart, and checkout functionality.

1. Models

First, let's define our data models:

class Product {
  final int id;
  final String name;
  final double price;
  final String imageUrl;

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

class CartItem {
  final Product product;
  final ValueNotifier<int> quantity;

  CartItem({required this.product, int initialQuantity = 1})
      : quantity = ValueNotifier(initialQuantity);
}

The Product class represents a product in our catalog. The CartItem class represents a product in the shopping cart, with a ValueNotifier for the quantity to allow for easy updates and UI rebuilds.

2. Services

Let's simulate a backend service:

class ApiService {
  Future<List<Product>> fetchProducts() async {
    // Simulate API delay
    await Future.delayed(Duration(seconds: 1));
    return [
      Product(id: 1, name: 'Laptop', price: 999.99, imageUrl: 'https://example.com/laptop.jpg'),
      Product(id: 2, name: 'Smartphone', price: 499.99, imageUrl: 'https://example.com/smartphone.jpg'),
      Product(id: 3, name: 'Headphones', price: 99.99, imageUrl: 'https://example.com/headphones.jpg'),
    ];
  }

  Future<bool> submitOrder(List<CartItem> items, double total) async {
    // Simulate API delay and always return success for this example
    await Future.delayed(Duration(seconds: 1));
    return true;
  }
}

This ApiService class simulates API calls to fetch products and submit orders.

3. State Management

Now, let's implement our state management classes:

class ProductCatalogNotifier {
  final ApiService _apiService;
  final ValueNotifier<List<Product>> _products = ValueNotifier([]);
  final ValueNotifier<bool> _isLoading = ValueNotifier(false);
  final ValueNotifier<String?> _error = ValueNotifier(null);

  ProductCatalogNotifier(this._apiService);

  ValueNotifier<List<Product>> get products => _products;
  ValueNotifier<bool> get isLoading => _isLoading;
  ValueNotifier<String?> get error => _error;

  Future<void> fetchProducts() async {
    _isLoading.value = true;
    _error.value = null;
    try {
      final fetchedProducts = await _apiService.fetchProducts();
      _products.value = fetchedProducts;
    } catch (e) {
      _error.value = 'Failed to fetch products: ${e.toString()}';
    } finally {
      _isLoading.value = false;
    }
  }

  void dispose() {
    _products.dispose();
    _isLoading.dispose();
    _error.dispose();
  }
}

This class encapsulates all the logic for managing product catalog state. By using ValueNotifiers, it can notify listeners (like UI widgets) whenever the products, loading state, or error state changes, allowing for reactive updates to the user interface.

The ProductCatalogNotifier manages the state of our product catalog. It uses separate ValueNotifiers for the product list, loading state, and error state. The fetchProducts method updates these states as it fetches products from the API.

Now, we need a cart to store our products before we submit them.

class CartNotifier {
  final ValueNotifier<List<CartItem>> _items = ValueNotifier([]);
  final ValueNotifier<double> _totalPrice = ValueNotifier(0.0);

  ValueNotifier<List<CartItem>> get items => _items;
  ValueNotifier<double> get totalPrice => _totalPrice;

  void addItem(Product product) {
    final existingItem = _items.value.firstWhere(
      (item) => item.product.id == product.id,
      orElse: () => CartItem(product: product, initialQuantity: 0),
    );

    if (existingItem.quantity.value == 0) {
      _items.value = [..._items.value, existingItem];
    }
    existingItem.quantity.value++;
    _updateTotalPrice();
  }

  void removeItem(Product product) {
    _items.value = _items.value.where((item) => item.product.id != product.id).toList();
    _updateTotalPrice();
  }

  void _updateTotalPrice() {
    _totalPrice.value = _items.value.fold(
      0.0,
      (total, item) => total + (item.product.price * item.quantity.value),
    );
  }

  void dispose() {
    for (var item in _items.value) {
      item.quantity.dispose();
    }
    _items.dispose();
    _totalPrice.dispose();
  }
}
  1. Purpose:

    This class manages the state of a shopping cart, including the items in the cart and the total price.

  2. Properties:

    _items: A ValueNotifier holding a list of CartItem objects. It represents the items in the cart.

    _totalPrice: A ValueNotifier holding a double. It represents the total price of all items in the cart.

  3. Getters:

    Provide access to the ValueNotifiers, allowing other parts of the app to listen to changes in the cart items and total price.

  4. addItem method:

    • Checks if the product already exists in the cart.

    • If it doesn't exist or has quantity 0, adds it to the cart.

    • Increments the quantity of the item.

    • Updates the total price.

  5. removeItem method:

    • Removes an item from the cart based on the product ID.

    • Updates the total price.

  6. _updateTotalPrice method:

    • Calculates the total price of all items in the cart.

    • Uses the fold method to sum up the prices of all items, considering their quantities.

  7. dispose method:

    • Cleans up resources by disposing of all ValueNotifiers, including those in individual CartItems.

This class encapsulates all the logic for managing the shopping cart state. By using ValueNotifiers, it can notify listeners (like UI widgets) whenever the cart items or total price changes, allowing for reactive updates to the user interface.

4. UI Implementation

Now, let's implement the UI for our product list page:

class ProductListPage extends StatefulWidget {
  @override
  _ProductListPageState createState() => _ProductListPageState();
}

class _ProductListPageState extends State<ProductListPage> {
  late final ProductCatalogNotifier _productCatalog;
  late final CartNotifier _cart;

  @override
  void initState() {
    super.initState();
    _productCatalog = ProductCatalogNotifier(ApiService());
    _cart = CartNotifier();
    WidgetsBinding.instance.addPostFrameCallback((_) {
      _productCatalog.fetchProducts();
    });
  }

  @override
  void dispose() {
    _productCatalog.dispose();
    _cart.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: Text('Product Catalog')),
      body: MultiValueListenableBuilder(
        valueListenables: [
          _productCatalog.isLoading,
          _productCatalog.error,
          _productCatalog.products,
        ],
        builder: (context, (bool isLoading, String? error, List<Product> products), _) {
          return switch ((isLoading, error, products)) {
            (true, _, _) => Center(child: CircularProgressIndicator()),
            (_, String error, _) => _buildErrorWidget(error),
            (_, _, []) => Center(child: Text('No products available')),
            (_, _, List<Product> products) => _buildProductList(products),
          };
        },
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => Navigator.push(
          context,
          MaterialPageRoute(builder: (_) => CartPage(_cart)),
        ),
        child: Icon(Icons.shopping_cart),
      ),
    );
  }

  Widget _buildErrorWidget(String error) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          Text(error),
          ElevatedButton(
            onPressed: () => _productCatalog.fetchProducts(),
            child: Text('Retry'),
          ),
        ],
      ),
    );
  }

  Widget _buildProductList(List<Product> products) {
    return RefreshIndicator(
      onRefresh: () => _productCatalog.fetchProducts(),
      child: ListView.builder(
        itemCount: products.length,
        itemBuilder: (context, index) {
          final product = products[index];
          return ListTile(
            title: Text(product.name),
            subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
            trailing: IconButton(
              icon: Icon(Icons.add_shopping_cart),
              onPressed: () => _cart.addItem(product),
            ),
          );
        },
      ),
    );
  }
}

Let's break down this implementation:

  1. We use a StatefulWidget to manage the lifecycle of our notifiers.

  2. In initState, we initialize our notifiers and fetch products after the widget is built.

  3. In dispose, we make sure to dispose of our notifiers to prevent memory leaks.

  4. We use MultiValueListenableBuilder to listen to multiple ValueNotifiers simultaneously.

  5. We use Dart's pattern matching feature to handle different states elegantly: Remember that this pattern matching syntax requires Dart 3.0 or later. If you're using an earlier version of Dart, you'll need to stick with the if-else approach or update your Dart SDK.

    • (true, , ) matches when loading.

    • (_, String error, _) matches when there's an error.

    • (_, _, []) matches when the product list is empty.

    • (_, _, List<Product> products) matches when we have products to display.

  6. We extract the error widget and product list widget into separate methods for better organization.

  7. We use a RefreshIndicator to allow users to manually refresh the product list.

Using Listenable.merge constructor:

ListenableBuilder(
        listenable: Listenable.merge([
          _productCatalog.loadingStatus,
          _productCatalog.errorMessage,
          _productCatalog.products,
        ]),
        builder: (context, _) {
          final status = _productCatalog.loadingStatus.value;
          final errorMessage = _productCatalog.errorMessage.value;
          final products = _productCatalog.products.value;

          return switch ((status, errorMessage, products)) {
            (LoadingStatus.loading, _, _) => 
              Center(child: CircularProgressIndicator()),
            (LoadingStatus.error, String error, _) => 
              Center(child: Text(error)),
            (_, _, List<Product> products) => ListView.builder(
              itemCount: products.length,
              itemBuilder: (context, index) {
                final product = products[index];
                return ListTile(
                  title: Text(product.name),
                  subtitle: Text('\$${product.price.toStringAsFixed(2)}'),
                );
              },
            ),
            _ => Center(child: Text('Unexpected state')),
          };
        },
      )

Now for the CartPage which will show our shopping cart.

class CartPage extends StatelessWidget {
  final CartNotifier cart;

  const CartPage({Key? key, required this.cart}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text('Shopping Cart'),
      ),
      body: ValueListenableBuilder<List<CartItem>>(
        valueListenable: cart.items,
        builder: (context, items, child) {
          if (items.isEmpty) {
            return Center(child: Text('Your cart is empty'));
          }
          return ListView.builder(
            itemCount: items.length,
            itemBuilder: (context, index) {
              final item = items[index];
              return ListTile(
                title: Text(item.product.name),
                subtitle: Text('\$${item.product.price.toStringAsFixed(2)}'),
                trailing: Row(
                  mainAxisSize: MainAxisSize.min,
                  children: [
                    IconButton(
                      icon: Icon(Icons.remove),
                      onPressed: () => cart.decreaseQuantity(item.product),
                    ),
                    ValueListenableBuilder<int>(
                      valueListenable: item.quantity,
                      builder: (context, quantity, child) {
                        return Text('$quantity');
                      },
                    ),
                    IconButton(
                      icon: Icon(Icons.add),
                      onPressed: () => cart.addItem(item.product),
                    ),
                  ],
                ),
              );
            },
          );
        },
      ),
      bottomNavigationBar: ValueListenableBuilder<double>(
        valueListenable: cart.totalPrice,
        builder: (context, totalPrice, child) {
          return Padding(
            padding: const EdgeInsets.all(8.0),
            child: Text(
              'Total: \$${totalPrice.toStringAsFixed(2)}',
              style: Theme.of(context).textTheme.headline6,
              textAlign: TextAlign.center,
            ),
          );
        },
      ),
    );
  }
}

5. Unit Tests

You might be wondering how to test your ValueNotifier.

Let’s test the ProductCatalogNotifier.

Our tests should cover:

  • Initial state of the product catalog

  • Successful fetching of products

  • Handling of errors during fetching

  • Loading state during fetching

  • Clearing of previous errors on new fetch

  • Replacement of previous products on new fetch

To write our test, we will use Mockito. Mockito is a popular mocking framework for Dart and Flutter. It allows you to create mock objects for your tests, which is particularly useful when you want to test classes that depend on other classes or services.

  1. Add Mockito and build_runner to your pubspec.yaml file under dev_dependencies to your project:
dev_dependencies:
  flutter_test:
    sdk: flutter
  mockito: ^5.0.0
  build_runner: ^2.0.0

Then run flutter pub get to install these packages.

  1. Create a test file:

Create a new file for your tests, e.g., test/product_catalog_notifier_test.dart.
We’ll use GenerateNiceMocks to generate mocks for our ApiService class. GenerateNiceMocks is a newer and often more convenient way to generate mocks using Mockito. It creates "nice" mocks by default, which means they return null for unexpected method calls instead of throwing exceptions. This can be helpful in many testing scenarios.

@GenerateNiceMocks([MockSpec<ApiService>()])
import 'product_catalog_notifier_test.mocks.dart';

void main() {
  late ProductCatalogNotifier productCatalog;
  late MockApiService mockApiService;

  setUp(() {
    mockApiService = MockApiService();
    productCatalog = ProductCatalogNotifier(mockApiService);
  });

  tearDown(() {
    productCatalog.dispose();
  });

  group('ProductCatalogNotifier', () {
    test('initial state is empty', () {
      expect(productCatalog.products.value, isEmpty);
      expect(productCatalog.isLoading.value, false);
      expect(productCatalog.error.value, isNull);
    });

    test('fetchProducts updates products on success', () async {
      final mockProducts = [
        Product(id: 1, name: 'Test Product 1', price: 10.0, imageUrl: 'test1.jpg'),
        Product(id: 2, name: 'Test Product 2', price: 20.0, imageUrl: 'test2.jpg'),
      ];

      when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts);

      await productCatalog.fetchProducts();

      expect(productCatalog.products.value, equals(mockProducts));
      expect(productCatalog.isLoading.value, false);
      expect(productCatalog.error.value, isNull);
    });

    test('fetchProducts updates error on failure', () async {
      when(mockApiService.fetchProducts()).thenThrow(Exception('API error'));

      await productCatalog.fetchProducts();

      expect(productCatalog.products.value, isEmpty);
      expect(productCatalog.isLoading.value, false);
      expect(productCatalog.error.value, contains('Failed to fetch products'));
    });

    test('isLoading is true while fetching products', () async {
      when(mockApiService.fetchProducts()).thenAnswer((_) async {
        await Future.delayed(Duration(milliseconds: 100));
        return [];
      });

      final future = productCatalog.fetchProducts();

      expect(productCatalog.isLoading.value, true);

      await future;

      expect(productCatalog.isLoading.value, false);
    });

    test('fetchProducts clears previous error', () async {
      // First, simulate an error
      when(mockApiService.fetchProducts()).thenThrow(Exception('API error'));
      await productCatalog.fetchProducts();
      expect(productCatalog.error.value, isNotNull);

      // Then, simulate a successful fetch
      when(mockApiService.fetchProducts()).thenAnswer((_) async => []);
      await productCatalog.fetchProducts();
      expect(productCatalog.error.value, isNull);
    });

    test('fetchProducts replaces previous products', () async {
      final mockProducts1 = [
        Product(id: 1, name: 'Test Product 1', price: 10.0, imageUrl: 'test1.jpg'),
      ];
      final mockProducts2 = [
        Product(id: 2, name: 'Test Product 2', price: 20.0, imageUrl: 'test2.jpg'),
      ];

      when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts1);
      await productCatalog.fetchProducts();
      expect(productCatalog.products.value, equals(mockProducts1));

      when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts2);
      await productCatalog.fetchProducts();
      expect(productCatalog.products.value, equals(mockProducts2));
    });
  });
}

Remember to run flutter pub run build_runner build

Let’s test our CartNotifier.
We’ll have a test suite that covers

  • Initial state of the cart

  • Adding a new item to an empty cart

  • Increasing quantity of an existing item

  • Adding different items separately

  • Removing an item from the cart

  • Attempting to remove a non-existent item

  • Updating total price with multiple operations

  • Edge case of adding an item with quantity 0 and then incrementing

  • Removing the last item and ensuring total price is 0

  • Handling high-priced items without precision errors

void main() {
  late CartNotifier cartNotifier;
  late Product product1;
  late Product product2;

  setUp(() {
    cartNotifier = CartNotifier();
    product1 = Product(id: 1, name: 'Test Product 1', price: 10.0, imageUrl: 'test1.jpg');
    product2 = Product(id: 2, name: 'Test Product 2', price: 20.0, imageUrl: 'test2.jpg');
  });

  tearDown(() {
    cartNotifier.dispose();
  });

  group('CartNotifier', () {
    test('initial state is empty', () {
      expect(cartNotifier.items.value, isEmpty);
      expect(cartNotifier.totalPrice.value, 0.0);
    });

    test('addItem adds new item to empty cart', () {
      cartNotifier.addItem(product1);
      expect(cartNotifier.items.value.length, 1);
      expect(cartNotifier.items.value[0].product, product1);
      expect(cartNotifier.items.value[0].quantity.value, 1);
      expect(cartNotifier.totalPrice.value, 10.0);
    });

    test('addItem increases quantity for existing item', () {
      cartNotifier.addItem(product1);
      cartNotifier.addItem(product1);
      expect(cartNotifier.items.value.length, 1);
      expect(cartNotifier.items.value[0].quantity.value, 2);
      expect(cartNotifier.totalPrice.value, 20.0);
    });

    test('addItem adds different items separately', () {
      cartNotifier.addItem(product1);
      cartNotifier.addItem(product2);
      expect(cartNotifier.items.value.length, 2);
      expect(cartNotifier.totalPrice.value, 30.0);
    });

    test('removeItem removes the item from the cart', () {
      cartNotifier.addItem(product1);
      cartNotifier.addItem(product2);
      cartNotifier.removeItem(product1);
      expect(cartNotifier.items.value.length, 1);
      expect(cartNotifier.items.value[0].product, product2);
      expect(cartNotifier.totalPrice.value, 20.0);
    });

    test('removeItem does nothing if item not in cart', () {
      cartNotifier.addItem(product1);
      cartNotifier.removeItem(product2);
      expect(cartNotifier.items.value.length, 1);
      expect(cartNotifier.items.value[0].product, product1);
      expect(cartNotifier.totalPrice.value, 10.0);
    });

    test('totalPrice updates correctly with multiple operations', () {
      cartNotifier.addItem(product1);
      cartNotifier.addItem(product2);
      cartNotifier.addItem(product1);
      expect(cartNotifier.totalPrice.value, 40.0);
      cartNotifier.removeItem(product2);
      expect(cartNotifier.totalPrice.value, 20.0);
    });

    test('adding item with quantity 0 and then incrementing works correctly', () {
      cartNotifier.addItem(product1);
      cartNotifier.items.value[0].quantity.value = 0;
      cartNotifier.addItem(product1);
      expect(cartNotifier.items.value.length, 1);
      expect(cartNotifier.items.value[0].quantity.value, 1);
      expect(cartNotifier.totalPrice.value, 10.0);
    });

    test('removing last item sets total price to 0', () {
      cartNotifier.addItem(product1);
      cartNotifier.removeItem(product1);
      expect(cartNotifier.items.value, isEmpty);
      expect(cartNotifier.totalPrice.value, 0.0);
    });

    test('adding item with very high price doesn\'t cause precision errors', () {
      final expensiveProduct = Product(id: 3, name: 'Expensive', price: 1000000.01, imageUrl: 'expensive.jpg');
      cartNotifier.addItem(expensiveProduct);
      expect(cartNotifier.totalPrice.value, 1000000.01);
    });
  });
}

Next, let’s add a widget test our ProductListPage. We are testing several scenarios:

  • Loading state when fetching products

  • Error state when fetching fails

  • Successful display of products

  • Adding a product to the cart

  • Navigating to the cart page

@GenerateNiceMocks([MockSpec<ApiService>(), MockSpec<CartNotifier>()])
import 'product_list_page_test.mocks.dart';

void main() {
  late MockApiService mockApiService;
  late MockCartNotifier mockCartNotifier;
  late ProductCatalogNotifier productCatalogNotifier;

  setUp(() {
    mockApiService = MockApiService();
    mockCartNotifier = MockCartNotifier();
    productCatalogNotifier = ProductCatalogNotifier(mockApiService);
  });

  Widget createWidgetUnderTest() {
    return MaterialApp(
      home: ProductListPage(
        productCatalog: productCatalogNotifier,
        cart: mockCartNotifier,
      ),
    );
  }

  group('ProductListPage', () {
    testWidgets('displays loading indicator when fetching products', (WidgetTester tester) async {
      when(mockApiService.fetchProducts()).thenAnswer((_) async {
        await Future.delayed(Duration(seconds: 1));
        return [];
      });

      await tester.pumpWidget(createWidgetUnderTest());

      expect(find.byType(CircularProgressIndicator), findsOneWidget);
    });

    testWidgets('displays error message when fetch fails', (WidgetTester tester) async {
      when(mockApiService.fetchProducts()).thenThrow(Exception('Failed to fetch products'));

      await tester.pumpWidget(createWidgetUnderTest());
      await tester.pumpAndSettle();

      expect(find.text('Error: Failed to fetch products'), findsOneWidget);
    });

    testWidgets('displays list of products when fetch succeeds', (WidgetTester tester) async {
      final mockProducts = [
        Product(id: 1, name: 'Test Product 1', price: 10.0, imageUrl: 'test1.jpg'),
        Product(id: 2, name: 'Test Product 2', price: 20.0, imageUrl: 'test2.jpg'),
      ];

      when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts);

      await tester.pumpWidget(createWidgetUnderTest());
      await tester.pumpAndSettle();

      expect(find.text('Test Product 1'), findsOneWidget);
      expect(find.text('Test Product 2'), findsOneWidget);
      expect(find.text('\$10.00'), findsOneWidget);
      expect(find.text('\$20.00'), findsOneWidget);
    });

    testWidgets('adds product to cart when add button is tapped', (WidgetTester tester) async {
      final mockProducts = [
        Product(id: 1, name: 'Test Product', price: 10.0, imageUrl: 'test.jpg'),
      ];

      when(mockApiService.fetchProducts()).thenAnswer((_) async => mockProducts);

      await tester.pumpWidget(createWidgetUnderTest());
      await tester.pumpAndSettle();

      await tester.tap(find.byIcon(Icons.add_shopping_cart));
      await tester.pump();

      verify(mockCartNotifier.addItem(mockProducts[0])).called(1);
    });

    testWidgets('navigates to cart page when cart icon is tapped', (WidgetTester tester) async {
      when(mockApiService.fetchProducts()).thenAnswer((_) async => []);

      await tester.pumpWidget(createWidgetUnderTest());
      await tester.pumpAndSettle();

      await tester.tap(find.byIcon(Icons.shopping_cart));
      await tester.pumpAndSettle();

      // Verify navigation to CartPage
      // This depends on how you've set up navigation. You might need to adjust this.
      expect(find.byType(CartPage), findsOneWidget);
    });
  });
}

We're using tester.pumpAndSettle() to wait for all animations to complete and the widget tree to settle. These tests cover the main functionality of the ProductListPage, including loading states, error handling, displaying products, adding to cart, and navigation. They help ensure that your UI behaves correctly under various conditions and that user interactions produce the expected results.

Remember to update these tests as you add new features or change the behavior of your ProductListPage. Widget tests are a powerful tool for maintaining the quality and reliability of your Flutter application's UI.

Finally, let’s add a widget test for our CartPage

@GenerateNiceMocks([MockSpec<CartNotifier>()])
import 'cart_page_test.mocks.dart';

void main() {
  late MockCartNotifier mockCartNotifier;

  setUp(() {
    mockCartNotifier = MockCartNotifier();
  });

  Widget createWidgetUnderTest() {
    return MaterialApp(
      home: CartPage(cart: mockCartNotifier),
    );
  }

  group('CartPage', () {
    testWidgets('displays empty cart message when cart is empty', (WidgetTester tester) async {
      when(mockCartNotifier.items).thenReturn(ValueNotifier([]));
      when(mockCartNotifier.totalPrice).thenReturn(ValueNotifier(0.0));

      await tester.pumpWidget(createWidgetUnderTest());

      expect(find.text('Your cart is empty'), findsOneWidget);
    });

    testWidgets('displays cart items when cart is not empty', (WidgetTester tester) async {
      final product = Product(id: 1, name: 'Test Product', price: 10.0, imageUrl: 'test.jpg');
      final cartItem = CartItem(product: product, quantity: ValueNotifier(2));

      when(mockCartNotifier.items).thenReturn(ValueNotifier([cartItem]));
      when(mockCartNotifier.totalPrice).thenReturn(ValueNotifier(20.0));

      await tester.pumpWidget(createWidgetUnderTest());

      expect(find.text('Test Product'), findsOneWidget);
      expect(find.text('\$10.00'), findsOneWidget);
      expect(find.text('2'), findsOneWidget);
      expect(find.text('Total: \$20.00'), findsOneWidget);
    });

    testWidgets('increases quantity when add button is tapped', (WidgetTester tester) async {
      final product = Product(id: 1, name: 'Test Product', price: 10.0, imageUrl: 'test.jpg');
      final cartItem = CartItem(product: product, quantity: ValueNotifier(1));

      when(mockCartNotifier.items).thenReturn(ValueNotifier([cartItem]));
      when(mockCartNotifier.totalPrice).thenReturn(ValueNotifier(10.0));

      await tester.pumpWidget(createWidgetUnderTest());

      await tester.tap(find.byIcon(Icons.add));
      await tester.pump();

      verify(mockCartNotifier.addItem(product)).called(1);
    });

    testWidgets('decreases quantity when remove button is tapped', (WidgetTester tester) async {
      final product = Product(id: 1, name: 'Test Product', price: 10.0, imageUrl: 'test.jpg');
      final cartItem = CartItem(product: product, quantity: ValueNotifier(2));

      when(mockCartNotifier.items).thenReturn(ValueNotifier([cartItem]));
      when(mockCartNotifier.totalPrice).thenReturn(ValueNotifier(20.0));

      await tester.pumpWidget(createWidgetUnderTest());

      await tester.tap(find.byIcon(Icons.remove));
      await tester.pump();

      verify(mockCartNotifier.decreaseQuantity(product)).called(1);
    });
  });
}

These tests cover the main functionality of the CartPage, including displaying cart items, showing the total price, and handling quantity changes.

Remember to run flutter pub run build_runner build to generate the mock classes before running the tests.

Let’s Wrap Up

ValueNotifier and ValueListenableBuilder provide a powerful yet simple way to manage state in Flutter applications. By using these tools along with custom widgets like MultiValueListenableBuilder and Dart's pattern matching feature, we can create responsive, efficient, and easy-to-maintain applications.

Remember to always dispose of your ValueNotifiers when they're no longer needed, and consider breaking down complex widgets into smaller, more manageable pieces. With these techniques, you can handle even complex state management scenarios with ease in your Flutter applications.

NOTE:
There are scenarios where ChangeNotifier might be more appropriate:

  • Complex State: When your state becomes more complex with multiple interdependent properties, ChangeNotifier can provide a more organized way to manage and update this state.

  • Multiple Listeners: If you need to notify multiple widgets about state changes and these changes might affect multiple properties at once, ChangeNotifier can be more efficient.

  • Business Logic: When you have significant business logic associated with your state updates, ChangeNotifier allows you to encapsulate this logic within the notifier class.

  • Computed Properties: If you need computed properties that depend on multiple pieces of state, ChangeNotifier can handle this more elegantly.

  • State Persistence: For cases where you need to persist state across app restarts or sync with external storage, ChangeNotifier can provide a centralized place to manage this.

  • Larger Applications: In larger applications with more complex state management needs, ChangeNotifier (often used with Provider) can offer a more scalable solution.

That said, it's important to note that you don't always need to jump to ChangeNotifier. For many cases, especially in smaller widgets or components, ValueNotifier and ValueListenableBuilder (or MultiValueListenableBuilder) can provide a simpler and more focused solution. The key is to choose the right tool for the job based on the complexity of your state management needs.

11
Subscribe to my newsletter

Read articles from Temitope Ajiboye directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Temitope Ajiboye
Temitope Ajiboye