Managing State with Provider
Published April 2026
Introduction
As your Flutter applications grow beyond simple single-screen prototypes, managing state becomes one of the most critical architectural decisions you will make. While calling setState() inside a StatefulWidget works well for local UI interactions (like toggling a checkbox or opening a menu), it quickly falls short when multiple screens need to share and mutate the same data.
For instance, consider a shopping cart application: when a user adds an item to their cart on a product detail page, the cart icon in the app bar on the main catalog screen must update its count, and the checkout summary screen must recalculate the total price. Trying to pass state down through multiple constructor parameters (known as "prop drilling") or lifting state up through deep widget hierarchies creates complex, brittle code that is difficult to maintain.
To solve this problem, Google recommends the Provider package as a clean, developer-friendly state management solution for intermediate applications. Provider acts as a wrapper around Flutter's built-in InheritedWidget system, allowing you to separate your business logic from the UI and share state variables across your widget tree. In this guide, you will learn the theory of state propagation, master core Provider APIs, and build a fully functional shopping cart dashboard.
Prerequisites
Before diving into Provider state propagation, ensure you have:
- Read our guide on Understanding Widgets in Flutter.
- Read our Stateless vs. Stateful Widgets guide.
- Your local development environment configured and running (refer to Your First Flutter App for setup details).
Core Explanation: The Provider Architecture
The provider library separates state from the UI using three core components: ChangeNotifier, ChangeNotifierProvider, and state listeners like Consumer and context lookups.
┌───────────────────────────────────────────────┐
│ PROVIDER WORKFLOW │
│ │
│ ChangeNotifier (State Logic) │
│ └── notifyListeners() │
│ │
│ ChangeNotifierProvider (Root Wrapper) │
│ └── Injects state into widget tree │
│ │
│ UI Listeners (Consumers & Contexts) │
│ ├── context.watch<T>() (Rebuilds widget)│
│ └── context.read<T>() (Calls action) │
└───────────────────────────────────────────────┘
1. ChangeNotifier (The State Manager)
A ChangeNotifier is a standard Dart class that encapsulates your application state and business logic. It provides a notifyListeners() method that tells Flutter to rebuild any widgets that are listening to changes in this class.
class CartState extends ChangeNotifier {
final List<String> _items = [];
List<String> get items => List.unmodifiable(_items);
void addItem(String item) {
_items.add(item);
notifyListeners(); // Signals listening widgets to rebuild
}
}
2. ChangeNotifierProvider (The Injector)
ChangeNotifierProvider is the widget responsible for instantiating the ChangeNotifier and injecting it into the widget tree. Any child widgets below this provider can now access and listen to the state.
3. State Subscription: Consumer vs. Context Lookups
To access your state class inside child widgets, Flutter offers several approaches:
context.watch<T>(): Listens for state changes. Rebuilds the entire widget whenever the state changes.context.read<T>(): Accesses the state class without listening to it. Ideal for triggering callbacks inside buttons (likeonPressed) since it won't cause the widget to rebuild when state variables update.Consumer<T>: A wrapper widget that rebuilds only its child subtree when state changes. This is the best practice for isolating rebuilds to keep your app fast.
Practical Example: A Complete Shopping Cart
Let's build a clean, compiler-ready shopping cart application using the Provider state management pattern.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
void main() {
runApp(
// 1. We wrap the root app in a ChangeNotifierProvider to inject state globally.
ChangeNotifierProvider(
create: (context) => CartProvider(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Provider Shopping Cart',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const CatalogPage(),
);
}
}
// ==========================================
// STATE PROVIDER LOGIC
// ==========================================
class CartProvider extends ChangeNotifier {
final List<String> _items = [];
List<String> get items => List.unmodifiable(_items);
double get totalPrice => _items.length * 15.0; // Each item costs $15
void addToCart(String itemName) {
if (!_items.contains(itemName)) {
_items.add(itemName);
// notifyListeners tells the framework to rebuild all listening widgets
notifyListeners();
}
}
void removeFromCart(String itemName) {
if (_items.contains(itemName)) {
_items.remove(itemName);
notifyListeners();
}
}
void clearCart() {
_items.clear();
notifyListeners();
}
}
// ==========================================
// CATALOG PAGE VIEW
// ==========================================
class CatalogPage extends StatelessWidget {
const CatalogPage({super.key});
@override
Widget build(BuildContext context) {
final catalogItems = ['Flutter Hoodie', 'Dart T-Shirt', 'Widget Sticker', 'API Key Chain', 'Firebase Coffee Mug'];
return Scaffold(
appBar: AppBar(
title: const Text('Store Catalog'),
actions: [
// Navigates to the cart page while showing item count badge
Stack(
children: [
IconButton(
icon: const Icon(Icons.shopping_cart),
onPressed: () {
Navigator.push(
context,
MaterialPageRoute(builder: (context) => const CartSummaryPage()),
);
},
),
Positioned(
right: 6,
top: 6,
child: Consumer<CartProvider>(
builder: (context, cart, child) {
if (cart.items.isEmpty) return const SizedBox.shrink();
return CircleAvatar(
radius: 8,
backgroundColor: Colors.red,
child: Text(
'${cart.items.length}',
style: const TextStyle(fontSize: 10, color: Colors.white),
),
);
},
),
),
],
),
const SizedBox(width: 8.0),
],
),
body: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: catalogItems.length,
itemBuilder: (context, index) {
final item = catalogItems[index];
return Card(
margin: const EdgeInsets.only(bottom: 12.0),
child: ListTile(
title: Text(item, style: const TextStyle(fontWeight: FontWeight.bold)),
subtitle: const Text('\$15.00'),
trailing: Consumer<CartProvider>(
builder: (context, cart, child) {
final inCart = cart.items.contains(item);
return ElevatedButton(
onPressed: inCart
? null
: () {
// 2. We use context.read because we only need to call a function,
// we do not want to trigger a build rebuild here.
context.read<CartProvider>().addToCart(item);
},
child: Text(inCart ? 'Added' : 'Add to Cart'),
);
},
),
),
);
},
),
);
}
}
// ==========================================
// CART SUMMARY PAGE
// ==========================================
class CartSummaryPage extends StatelessWidget {
const CartSummaryPage({super.key});
@override
Widget build(BuildContext context) {
// 3. We use context.watch here because we need to rebuild the entire summary page
// when items are removed from the cart.
final cart = context.watch<CartProvider>();
return Scaffold(
appBar: AppBar(
title: const Text('Your Cart'),
actions: [
IconButton(
icon: const Icon(Icons.delete_sweep),
onPressed: cart.items.isEmpty ? null : () => cart.clearCart(),
),
],
),
body: cart.items.isEmpty
? const Center(child: Text('Your cart is empty', style: TextStyle(fontSize: 16)))
: Column(
children: [
Expanded(
child: ListView.builder(
padding: const EdgeInsets.all(16.0),
itemCount: cart.items.length,
itemBuilder: (context, index) {
final item = cart.items[index];
return Card(
child: ListTile(
title: Text(item),
trailing: IconButton(
icon: const Icon(Icons.remove_circle, color: Colors.red),
onPressed: () {
context.read<CartProvider>().removeFromCart(item);
},
),
),
);
},
),
),
Container(
padding: const EdgeInsets.all(24.0),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.primaryContainer,
borderRadius: const BorderRadius.vertical(top: Radius.circular(24.0)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Text('Total Price:', style: TextStyle(fontSize: 14)),
Text(
'\$${cart.totalPrice.toStringAsFixed(2)}',
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
],
),
ElevatedButton(
onPressed: () {
// Handle checkout logic
},
child: const Text('Checkout'),
),
],
),
),
],
),
);
}
}
Common Mistakes
1. Using context.watch() inside Event Handlers
Calling context.watch() inside button callbacks (like onPressed) causes the widget containing the button to rebuild unnecessarily whenever state properties modify.
// BAD PRACTICE
ElevatedButton(
onPressed: () {
// context.watch() cannot be called inside action callbacks
context.watch<CartProvider>().addToCart(item);
},
child: const Text('Buy'),
)
- The Fix: Use
context.read<T>()to access actions and trigger events inside callbacks.
2. Mutating State Directly without Notification
Modifying data values inside your ChangeNotifier without invoking notifyListeners() will update the data in memory but won't trigger rebuilds to update the visual UI on screen.
- The Fix: Always call
notifyListeners()at the end of any method that modifies state variables.
3. Missing Providers on Root Contexts
A common error is getting a ProviderNotFoundException. This happens when you attempt to watch a provider in a widget that lives above or outside the ChangeNotifierProvider root context.
- The Fix: Always wrap the provider around the root of the widget subtree that needs access to the state (e.g. at the top of your app in
main.dart).
Best Practices
- Keep Rebuild Scopes Small: Wrap only the specific widget that needs to update in a
Consumerwidget rather than rebuilding the entire screen. - Use
context.readfor Functions: Always usecontext.readinstead ofcontext.watchinside buttons or dynamic event listeners to improve app performance. - Keep Business Logic Out of Widgets: Keep widget build methods pure by moving all network requests, calculations, and data processing into your state classes.
Related Concepts
Continue mastering state management in Flutter by exploring these guides:
- Understanding Widgets in Flutter: Learn the difference between layouts and the rendering layer.
- Stateless vs. Stateful Widgets: Understand the lifecycle methods of stateless and stateful widgets.
- Riverpod Fundamentals: Explore advanced state management architectures and dependency injection patterns.
Summary
- Provider separates app logic from UI layouts, eliminating the need to pass data down through widget constructors.
ChangeNotifierclasses encapsulate state variables and notify widgets to update by callingnotifyListeners().- Wrap root widgets in a
ChangeNotifierProviderto inject state. - Use
context.watchto listen for state changes and rebuild widgets, andcontext.readto trigger action callbacks.
Next Steps
- Previous Guide: Building Responsive Layouts
- Next Guide: Riverpod Fundamentals
- Related Guide: Stateless vs. Stateful Widgets
Ready to take the next step?
Start our free Flutter course and build real apps today.
Start Learning Now