You don't need State Managment libraries in Flutter

You don't need State Managment libraries in Flutter


flutterdartblocriverpodtutorialsstate managementprovidervalue notifier

Flutter already has a lot of built-in tools to manage state like setState or ChangeNotifier. You don’t need to use external libraries like bloc, provider, riverpod, etc. to manage simple state in your Flutter app.
In this article, I will show you how to manage state in Flutter without using any external libraries, and avoid the complexity that comes with them, when the built-in tools are enough for most use cases.

ValueNotifier

Every state management library solution in Flutter, including popular ones like bloc, provider, and riverpod, is built around the observer pattern.

The observer pattern is a software design pattern in which an object, called the subject, maintains a list of its dependents, called observers, and notifies them of state changes, typically by calling one of their methods.
In Flutter, the simplest implementation of this pattern is ValueNotifier, a class in Flutter that holds a value and notifies its listeners when the value changes.

To learn more about ValueNotifier, you can refer to the official Flutter documentation.

Example

Let’s create a simple shopping cart app to demonstrate how to use ValueNotifier to manage state in Flutter.

iPhone emulator showing the power switch app

Creating the product model

First, create a Product class to represent a shopping cart product. Note that the Product class extends Equatable to implement value based equality. This means that two Product instances are considered equal if their properties are equal. More on this in the Equatable package documentation.

@immutable
class Product extends Equatable {
  const Product(this.name, this.price);

  final String name;
  final double price;

  // Implementing Equatable to compare objects by value.
  @override
  List<Object?> get props => [name, price];

  bool get isEmpty => name.isEmpty || price == 0;

  Product copyWith({
    String? name,
    double? price,
  }) {
    return Product(
      name ?? this.name,
      price ?? this.price,
    );
  }
}

Creating the ProductFormNotifier

Let’s create our first ValueNotifier to manage the product form state. This notifier will hold the current product being edited in the form.

// Global instance of ProductFormNotifier
final $productFormNotifier = ProductFormNotifier();

class ProductFormNotifier extends ValueNotifier<Product> {
  // The initial value of the notifier is an empty product
  ProductFormNotifier() : super(const Product('', 0));

  void setName(String name) {
    value = value.copyWith(name: name);
  }

  void setPrice(double price) {
    value = value.copyWith(price: price);
  }
}

We need to update the value of the ValueNotifier to notify its listeners.
We can do this by calling the value setter. This value has to be a new instance of the product, so we use the copyWith method to create a new instance of the product with the new value.

As the docs for ValueNotifier say:

When the value is replaced with something that is not equal to the old value as evaluated by the equality operator ==, this class notifies its listeners.

Creating the CartNotifier

Next, let’s create a ValueNotifier to hold the list of products in the shopping cart.

// Global instance of CartNotifier
final $cartNotifier = CartNotifier();

class CartNotifier extends ValueNotifier<List<Product>> {
  // The initial value of the notifier is an empty list
  CartNotifier() : super(const []);

  void add(Product product) {
    value = [...value, product];
  }

  void remove(Product product) {
    value = value.where((c) => c != product).toList();
  }
}

We update the value to a new instance of the list, so we use the spread operator ... to create a new list with the new product, or we use the where method to filter out the product we want to remove from the list.

Creating the UI

Now let’s create a simple UI to display the product form.

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

  @override
  Widget build(BuildContext context) {
    return Padding(
      padding: const EdgeInsets.all(16.0),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            // We use the setName method of the global instance of ProductFormNotifier
            // to update the name of the product
            onChanged: (value) => $productFormNotifier.setName(value),
            decoration: const InputDecoration(
              labelText: 'Name',
            ),
          ),
          const SizedBox(height: 16),
          TextField(
            // We use the setPrice method of the global instance of ProductFormNotifier
            // to update the price of the product
            onChanged: (value) =>
                $productFormNotifier.setPrice(double.tryParse(value) ?? 0),
            keyboardType: TextInputType.number,
            decoration: const InputDecoration(
              labelText: 'Price',
            ),
          ),
          const SizedBox(height: 16),
          ValueListenableBuilder(
            valueListenable: $productFormNotifier,
            builder: (context, product, _) {
              return ElevatedButton.icon(
                icon: const Icon(Icons.shopping_cart),
                onPressed:
                    // If the product is empty, we disable the button
                    product.isEmpty ? null : () => $cartNotifier.add(product),
                label: const Text('Add to cart'),
              );
            },
          ),
        ],
      ),
    );
  }
}

In this UI, we have two TextField widgets to input the name and price of the product. We use the onChanged callback to update the product form state when the user types in the TextField.

We also have a ValueListenableBuilder widget to listen to changes in the product form state. This widget rebuilds the UI whenever the product form state changes.

You may find similarity between ValueListenableBuilder and Consumer from provider or BlocBuilder from bloc package, and that’s because they are all built around the same principle of the observer pattern. These widgets ensure that only the builder function is rebuilt when the value changes, avoiding unnecessary rebuilds.

// .....
  ValueListenableBuilder(
    valueListenable: $productFormNotifier,
    builder: (context, product, _) {
      return ElevatedButton.icon(
        icon: const Icon(Icons.shopping_cart),
        onPressed:
            // If the product is empty, we disable the button
            product.isEmpty ? null : () => $cartNotifier.add(product),
        label: const Text('Add to cart'),
      );
    },
  ),
// .....

Now let’s create a simple UI to display the products in the shopping cart.

class ProductList extends StatelessWidget {
  const ProductList({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
        // Here we are listening to the global instance of CartNotifier
        valueListenable: $cartNotifier,
        builder: (context, products, _) {
          return ListView(
            shrinkWrap: true,
            children: products
                .map((product) => ProductItem(product))
                .toList(growable: false),
          );
        });
  }
}

The ProductList widget listens to changes in the shopping cart state using the ValueListenableBuilder widget. This widget rebuilds the UI whenever we add or remove a product from the shopping cart.

class ProductItem extends StatelessWidget {
  const ProductItem(this.product, {Key? key}) : super(key: key);

  final Product product;

  @override
  Widget build(BuildContext context) {
    return ListTile(
      title: Text(product.name),
      trailing: IconButton(
        // We use the remove method of the global instance of CartNotifier
        // to remove the product from the cart
        onPressed: () => $cartNotifier.remove(product),
        icon: const Icon(Icons.remove_circle),
      ),
      subtitle: Text('\$${product.price}'),
    );
  }
}

The ProductItem widget displays the name and price of the product in a ListTile widget. We also have an IconButton widget to remove the product from the shopping cart.

Finally, let’s create the main app widget to display the product form and the shopping cart.

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

  final String title;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: Text(title),
      ),
      body: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
        const ProductForm(),
        Padding(
          padding: const EdgeInsets.all(16.0),
          child: ValueListenableBuilder(
              valueListenable: $cartNotifier,
              builder: (context, products, _) {
                return Text(
                  // We are displaying the number of products in the cart
                  'Added to cart (${products.length})',
                  style: Theme.of(context).textTheme.titleLarge,
                );
              }),
        ),
        const Expanded(child: ProductList()),
      ]),
    );
  }
}

That’s it! We’ve created a simple shopping cart app using ValueNotifier to manage state in Flutter without using any external libraries 🎉.

You can find the complete code for this example on GitHub

Conclusion

Remember, when it comes to state management in Flutter, it’s important to choose the approach that best fits your app’s needs and complexity. While libraries like bloc, provider, and riverpod offer powerful features and abstractions, sometimes a simpler solution like ValueNotifier can be sufficient.

By using ValueNotifier, you can manage state in your Flutter app without relying on external libraries. It provides a lightweight and straightforward solution for managing state, avoiding the complexity and boilerplate code that can come with using additional libraries.

In my opinion, this use case fits best when you have a small to medium-sized app with simple state management requirements, otherwise, you might want to consider using a more advanced state management solution and patterns like Bloc + rxdart wich I cover in another article.

Regardless of the approach you choose, the most important thing is to understand the principles behind state management in Flutter and choose the solution that best fits your app’s requirements.

I hope you found this article helpful. If you have any questions or feedback, feel free to contact me on Twitter or email me at mail@lorensala.com

Happy coding! 🚀

© 2024 Lorenzo Sala

Made with ❤️ and ☕