Flutter: State Management with Provider
Last updated:
5 min read

Provider is one of the most popular packages for state management in Flutter; the Flutter team's own simple state management guide teaches its examples with this package. It is used to share data across the widget tree and listen to changes.
What is State and Why Should It Be Managed?
State is the data condition your application has at any given moment. For example:
- Whether the user is logged in
- Number of items in the cart
- Theme preference (light/dark)
- Values in form fields
- Data from APIs
Without state management, sharing this data between widgets and reflecting updates becomes very difficult.
Limitations of setState
Flutter uses setState for simple state management; where setState sits inside a StatefulWidget and when it runs is covered in the life cycle post. However, as your app grows, you face serious problems:
1. Prop Drilling Problem
// You have to pass data 5 levels down
GrandParent(
child: Parent(
child: Child(
child: GrandChild(
child: GreatGrandChild(
userData: userData, // Must be passed all the way!
),
),
),
),
)2. Unnecessary Rebuilds
When you use setState, the entire widget rebuilds - not just the changed part.
3. State Loss
State can be lost when navigating the widget tree. Data resets when you change pages and return.
4. Untestability
Writing unit tests becomes difficult when UI and business logic are intertwined.
5. Code Repetition
You need to constantly pass parameters to use the same data in multiple places.
How Provider Solves These Problems
✅ Centralized State Management
Data is managed from a single place, accessible from everywhere.
✅ Efficient Rebuilds
Only changed widgets are rebuilt.
✅ Separation of Concerns
Business logic and UI are separated from each other.
✅ Easy Testability
You can easily write tests by mocking Providers.
✅ No Prop Drilling
Widgets that need data read directly from Provider.
When Should You Use Provider?
| Situation | setState | Provider |
|---|---|---|
| Simple counter in single widget | ✅ | ❌ |
| Form validation | ✅ | ❌ |
| User info across multiple pages | ❌ | ✅ |
| Cart management | ❌ | ✅ |
| Theme/Language settings | ❌ | ✅ |
| Data from APIs | ❌ | ✅ |
| Complex form states | ❌ | ✅ |
General Rule: If state is used by multiple widgets or pages, consider Provider.
Why Provider?
| Feature | setState | Provider |
|---|---|---|
| Scope | Single widget | Entire widget tree |
| Complexity | Simple | Medium |
| Scalability | Hard | Easy |
| Testability | Hard | Easy |
| Prop drilling | Required | Not needed |
If the BuildContext dependency and the lack of compile-time safety bother you, the next step on the same idea is Riverpod: providers are declared globally, no context is needed, and reading the wrong type fails at compile time.
⚠️ Important Note: Provider is an external package and does not work in DartPad. To run the examples on your computer, follow the installation steps below.
Installation
You can check the current version on the provider package page on pub.dev; add it to your pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
provider: ^6.1.1Then run in terminal:
flutter pub getCore Concepts
1. ChangeNotifier
Class that holds state and notifies changes; the ChangeNotifier API docs, part of Flutter itself, describe adding listeners and how notifyListeners behaves:
import 'package:flutter/foundation.dart';
class CounterProvider extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners(); // Notify listeners
}
void decrement() {
_count--;
notifyListeners();
}
void reset() {
_count = 0;
notifyListeners();
}
}2. ChangeNotifierProvider
Adding Provider to widget tree:
import 'package:provider/provider.dart';
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CounterProvider(),
child: MyApp(),
),
);
}3. Consumer
Listening to state and updating UI:
Consumer<CounterProvider>(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: TextStyle(fontSize: 48),
);
},
)4. context.read and context.watch
// Read (doesn't listen to changes) - Use in buttons
context.read<CounterProvider>().increment();
// Watch (listens to changes) - Use in build method
final count = context.watch<CounterProvider>().count;Full Example: Counter App
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
// 1. State class
class CounterProvider extends ChangeNotifier {
int _count = 0;
int get count => _count;
void increment() {
_count++;
notifyListeners();
}
void decrement() {
_count--;
notifyListeners();
}
}
// 2. Main
void main() {
runApp(
ChangeNotifierProvider(
create: (context) => CounterProvider(),
child: MaterialApp(
home: CounterPage(),
),
),
);
}
// 3. UI
class CounterPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('Provider Counter')),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('Counter Value:'),
Consumer<CounterProvider>(
builder: (context, counter, child) {
return Text(
'${counter.count}',
style: TextStyle(fontSize: 72, fontWeight: FontWeight.bold),
);
},
),
SizedBox(height: 32),
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FloatingActionButton(
onPressed: () => context.read<CounterProvider>().decrement(),
child: Icon(Icons.remove),
),
SizedBox(width: 16),
FloatingActionButton(
onPressed: () => context.read<CounterProvider>().increment(),
child: Icon(Icons.add),
),
],
),
],
),
),
);
}
}MultiProvider
Using multiple providers:
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => CounterProvider()),
ChangeNotifierProvider(create: (_) => ThemeProvider()),
ChangeNotifierProvider(create: (_) => UserProvider()),
],
child: MyApp(),
),
);
}Real World Example: Theme Switching
// Theme Provider
class ThemeProvider extends ChangeNotifier {
ThemeMode _themeMode = ThemeMode.light;
ThemeMode get themeMode => _themeMode;
bool get isDarkMode => _themeMode == ThemeMode.dark;
void toggleTheme() {
_themeMode = isDarkMode ? ThemeMode.light : ThemeMode.dark;
notifyListeners();
}
}
// Main
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => ThemeProvider(),
child: MyApp(),
),
);
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<ThemeProvider>(
builder: (context, themeProvider, child) {
return MaterialApp(
themeMode: themeProvider.themeMode,
theme: ThemeData.light(),
darkTheme: ThemeData.dark(),
home: HomePage(),
);
},
);
}
}
class HomePage extends StatelessWidget {
@override
Widget build(BuildContext context) {
final themeProvider = context.watch<ThemeProvider>();
return Scaffold(
appBar: AppBar(title: Text('Theme Settings')),
body: Center(
child: SwitchListTile(
title: Text('Dark Mode'),
value: themeProvider.isDarkMode,
onChanged: (_) => themeProvider.toggleTheme(),
),
),
);
}
}As written, the preference only lives in memory and the app goes back to the light theme after a restart. To make the choice persistent, the local data storage with SharedPreferences post shows step by step how the same kind of ChangeNotifier writes the value to disk and reads it back at startup.
Real World Example: Shopping Cart
// Product model
class Product {
final String id;
final String name;
final double price;
Product({required this.id, required this.name, required this.price});
}
// Cart Provider
class CartProvider extends ChangeNotifier {
final List<Product> _items = [];
List<Product> get items => List.unmodifiable(_items);
int get itemCount => _items.length;
double get totalPrice => _items.fold(0, (sum, item) => sum + item.price);
void addItem(Product product) {
_items.add(product);
notifyListeners();
}
void removeItem(String productId) {
_items.removeWhere((item) => item.id == productId);
notifyListeners();
}
void clearCart() {
_items.clear();
notifyListeners();
}
}
// Usage
class ProductCard extends StatelessWidget {
final Product product;
ProductCard({required this.product});
@override
Widget build(BuildContext context) {
return Card(
child: ListTile(
title: Text(product.name),
subtitle: Text('\$${product.price}'),
trailing: IconButton(
icon: Icon(Icons.add_shopping_cart),
onPressed: () {
context.read<CartProvider>().addItem(product);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${product.name} added to cart')),
);
},
),
),
);
}
}
// Cart icon (in AppBar)
class CartIcon extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Stack(
children: [
IconButton(
icon: Icon(Icons.shopping_cart),
onPressed: () {
// Navigate to cart page
},
),
Positioned(
right: 0,
top: 0,
child: Consumer<CartProvider>(
builder: (context, cart, child) {
return cart.itemCount > 0
? CircleAvatar(
radius: 10,
backgroundColor: Colors.red,
child: Text(
'${cart.itemCount}',
style: TextStyle(fontSize: 12, color: Colors.white),
),
)
: SizedBox.shrink();
},
),
),
],
);
}
}Optimization with Selector
Listen to specific changes only:
// Listens to all changes (inefficient)
Consumer<CartProvider>(
builder: (context, cart, child) {
return Text('${cart.itemCount} items');
},
)
// Rebuilds only when itemCount changes (efficient)
Selector<CartProvider, int>(
selector: (context, cart) => cart.itemCount,
builder: (context, itemCount, child) {
return Text('$itemCount items');
},
)Provider Types
| Type | Usage |
|---|---|
Provider |
Immutable values |
ChangeNotifierProvider |
Mutable state (most common) |
FutureProvider |
Async data |
StreamProvider |
Stream data |
ProxyProvider |
Dependent providers |
FutureProvider Example
FutureProvider<List<Product>>(
create: (_) => fetchProducts(),
initialData: [],
child: ProductList(),
)Best Practices
1. Define Provider as high as possible
// ✅ Correct - In main
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => MyProvider(),
child: MyApp(),
),
);
}
// ❌ Wrong - Deep in tree
class SomePage extends StatelessWidget {
Widget build(BuildContext context) {
return ChangeNotifierProvider(
create: (_) => MyProvider(), // New instance on every build!
child: ...,
);
}
}2. context.read vs context.watch
// ✅ Use watch in build method
Widget build(BuildContext context) {
final count = context.watch<CounterProvider>().count;
return Text('$count');
}
// ✅ Use read in event handlers
onPressed: () {
context.read<CounterProvider>().increment();
}
// ❌ Don't use read in build method
Widget build(BuildContext context) {
final count = context.read<CounterProvider>().count; // Won't update
return Text('$count');
}3. Keep Consumer as narrow as possible
// ✅ Correct - Only necessary part rebuilds
Scaffold(
appBar: AppBar(title: Text('Page')),
body: Consumer<CounterProvider>(
builder: (context, counter, child) {
return Text('${counter.count}');
},
),
)
// ❌ Wrong - Entire page rebuilds
Consumer<CounterProvider>(
builder: (context, counter, child) {
return Scaffold(
appBar: AppBar(title: Text('Page')),
body: Text('${counter.count}'),
);
},
)Summary
Provider: the recommended state management solution for FlutterChangeNotifier: the class that holds state and notifies listenersnotifyListeners(): called to update the UIConsumer: listens to state changescontext.watch: use in build (listens)context.read: use in events (doesn't listen)MultiProvider: multiple providersSelector: performance optimization
Provider is a powerful and flexible state management solution that can be used in Flutter projects of all sizes. When a single screen owns its own data, setState is still the simplest route, and for painting a one-off async result on screen, FutureBuilder is often all you need.
Frequently Asked Questions
Should I use Provider or setState?
If the state lives inside a single widget and nothing outside reads it, setState is the simplest and fastest option. Once several screens read or update the same data, move to Provider — the real win is getting rid of prop drilling.
What is the difference between context.read and context.watch?
context.watch reads the value and subscribes that widget to changes, so it belongs in build. context.read does a one-off read without subscribing and is the right choice inside event handlers such as a button's onPressed.
I called notifyListeners() but the UI doesn't update — why?
Two causes account for most cases: the listening widget uses context.read (which never subscribes), or you mutated a list in place and handed back the same reference. Make the change inside the ChangeNotifier, call notifyListeners(), and make sure the reading side uses context.watch or a Consumer.
Related Posts
Flutter: Modern State Management with Riverpod 3
State management in Flutter with Riverpod 3: Notifier, AsyncNotifier, ref.watch vs ref.read, family, autoDispose and migration notes for old StateNotifier code.
Flutter Life Cycle: StatefulWidget and AppLifecycleState
Understanding Flutter StatefulWidget life cycle methods and application life cycle management. Using initState, dispose, didUpdateWidget, and setState.
Navigation and Data Transfer Between Pages in Flutter
Page navigation, data transfer and back stack management with Flutter's Navigator: push, pop, pushReplacement and the current PopScope for the back button.