Provider #
The Provider library is a state management solution that was historically one of the main official recommendations from the Flutter development team. Fundamentally, Provider acts as a smart wrapper on top of Flutter’s built-in InheritedWidget. It’s designed to simplify complicated boilerplate code, inject dependencies dynamically, and provide very tidy object lifecycle management features like lazy initialization and automatic disposal. Although Riverpod is often more recommended for new projects today, mastering Provider is very important because it still dominates millions of active production projects and library packages in the global Flutter ecosystem.
Installation #
To use Provider, add the following dependency to your project’s pubspec.yaml file:
dependencies:
flutter:
sdk: flutter
provider: ^6.1.2
ChangeNotifierProvider — The Most Common Provider #
ChangeNotifierProvider is the most frequently used provider type in Flutter app development. It pairs the usefulness of ChangeNotifier as a reactive data emitter with InheritedWidget as the vertical data pipeline down the widget tree.
1. Creating the Reactive Model Class #
Create your business logic model by inheriting the ChangeNotifier class.
import 'package:flutter/foundation.dart';
class CartItem {
final String id;
final String name;
final double price;
final int quantity;
const CartItem({
required this.id,
required this.name,
required this.price,
required this.quantity,
});
double get subtotal => price * quantity;
CartItem copyWith({int? quantity}) {
return CartItem(
id: id,
name: name,
price: price,
quantity: quantity ?? this.quantity,
);
}
}
class CartModel extends ChangeNotifier {
final List<CartItem> _items = [];
List<CartItem> get items => List.unmodifiable(_items);
int get itemCount => _items.fold(0, (sum, item) => sum + item.quantity);
double get totalSpending => _items.fold(0.0, (sum, item) => sum + item.subtotal);
void addProduct(String id, String name, double price) {
final index = _items.indexWhere((item) => item.id == id);
if (index >= 0) {
_items[index] = _items[index].copyWith(quantity: _items[index].quantity + 1);
} else {
_items.add(CartItem(id: id, name: name, price: price, quantity: 1));
}
notifyListeners(); // Triggers UI updates
}
void removeProduct(String id) {
_items.removeWhere((item) => item.id == id);
notifyListeners();
}
void reset() {
_items.clear();
notifyListeners();
}
}
2. Registering the Provider in the Widget Tree #
To make the data status from CartModel accessible to widgets below, you must wrap your app’s entry point (or a specific subtree) with ChangeNotifierProvider.
void main() {
runApp(
// Placing ChangeNotifierProvider above MaterialApp so it can be accessed from any page
ChangeNotifierProvider(
create: (BuildContext context) => CartModel(),
lazy: true, // Defaults to true (only initialized when first accessed)
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(
home: MainScreen(),
);
}
}
Automatic Memory Management: The main advantage ofChangeNotifierProvideris that it automatically calls thedispose()method on theCartModelobject when the provider widget is permanently removed from the widget tree, so you’re free from memory leak risks.
Accessing the Provider — Three Extension Methods #
The Provider library provides three practical extension methods on BuildContext to interact with data status registered in the widget tree:
flowchart TD
Start["Need Provider Access?"] --> Where{"Where is the access location?"}
Where -->|Inside a build method| RebuildNeeded{"Does the widget need\nto rebuild when state changes?"}
RebuildNeeded -->|Yes| PartialNeeded{"Does it only need\npart of the state properties?"}
PartialNeeded -->|Yes| UseSelect["Use context.select()"]
PartialNeeded -->|No| UseWatch["Use context.watch()"]
RebuildNeeded -->|No| UseRead["Use context.read()"]
Where -->|Outside build / callbacks| UseReadCallback["Use context.read()"]1. context.watch() (Full Subscription) #
The context.watch<T>() method reads the current data status of type T and simultaneously registers the calling widget as a dependent. Every time the notifyListeners() method on object T is called, widgets using context.watch will be rebuilt completely.
- When to Use: Inside the
build()method of widgets responsible for displaying dynamic information directly to the screen.
class ShoppingCartIcon extends StatelessWidget {
const ShoppingCartIcon({super.key});
@override
Widget build(BuildContext context) {
// This widget will be automatically rebuilt every time there's a new item in the cart
final cart = context.watch<CartModel>();
return Badge(
label: Text('${cart.itemCount}'),
child: const Icon(Icons.shopping_cart),
);
}
}
2. context.read() (Read Only, No Rebuild) #
The context.read<T>() method reads the current T object without registering the widget as an active listener. In other words, calling this method will never trigger a reconstruction of the calling widget when data status updates.
- When to Use: Inside user interaction callback functions like button
onPressed, input fieldonChanged, or inside initialinitStateinitialization.
class AddToCartButton extends StatelessWidget {
final String productId;
final String productName;
final double productPrice;
const AddToCartButton({
super.key,
required this.productId,
required this.productName,
required this.productPrice,
});
@override
Widget build(BuildContext context) {
return ElevatedButton(
onPressed: () {
// Triggering business logic without pointlessly rebuilding this button
context.read<CartModel>().addProduct(productId, productName, productPrice);
},
child: const Text('Add to Cart'),
);
}
}
3. context.select<T, R>() (Targeted Subscription) #
The context.select<T, R>() method lets you only monitor a specific property of type R from the T status object. The calling widget will only rebuild when that specific property changes value, ignoring changes to other properties in the same model.
- When to Use: On micro UI components that only need a small piece of information from a large model for strict render performance optimization.
class TotalBillWidget extends StatelessWidget {
const TotalBillWidget({super.key});
@override
Widget build(BuildContext context) {
// Only rebuilds if totalSpending changes.
// Changes to the item list won't affect this widget if its value stays the same.
final total = context.select<CartModel, double>(
(cart) => cart.totalSpending,
);
return Text(
'Total: Rp $total',
style: const TextStyle(fontSize: 20.0, fontWeight: FontWeight.bold),
);
}
}
Consumer — An Alternative Builder Widget #
The Consumer<T> widget serves as an alternative reactive writing style besides using context.watch<T>(). Consumer’s main advantage is its ability to limit the scope of UI updates more precisely through the use of a cached static child parameter.
class MainCartScreen extends StatelessWidget {
const MainCartScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
body: Consumer<CartModel>(
// The static child is declared outside the builder to stay free from rebuild cycles
child: const Padding(
padding: EdgeInsets.all(16.0),
child: Text('Your Shopping Summary:'),
),
builder: (BuildContext context, CartModel cart, Widget? staticChild) {
return Column(
children: [
staticChild!, // Reusing the already-cached static widget
Text('Active Items: ${cart.itemCount}'),
Text('Total: Rp ${cart.totalSpending}'),
],
);
},
),
);
}
}
When to Choose context.watch vs Consumer? #
- Use
context.watch<T>(): When your build code is relatively short and almost all widget content in that build function truly depends on data status changes. This way produces cleaner, more concise code structure. - Use
Consumer<T>: When you have a widget with a very long build method containing many static visual elements. By placingConsumerat the deepest level, you isolate the rebuild cycle so it doesn’t affect neighboring widgets.
MultiProvider — Avoiding Code Nesting #
As your app grows, you’ll definitely manage many status models at once. Stacking Provider widgets manually one by one will trigger code readability problems (nesting hell). To solve this, you use MultiProvider.
// AVOID: Nesting patterns that make code hard to read
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => AuthModel(),
child: ChangeNotifierProvider(
create: (_) => ThemeModel(),
child: MaterialApp(home: const MainScreen()),
),
),
);
}
// USE: A tidy, structured MultiProvider
void main() {
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (context) => AuthModel()),
ChangeNotifierProvider(create: (context) => ThemeModel()),
ChangeNotifierProvider(create: (context) => CartModel()),
],
child: const MyApp(),
),
);
}
ProxyProvider — Managing Inter-Provider Dependencies #
In real-world apps, a status module often needs information from other status modules. For example, an API data service (ApiService) needs an authentication token from the user authentication model (AuthModel) to make valid data calls. For this dependency scenario, you use ProxyProvider.
flowchart LR
Auth["AuthModel (Manages Token)"] -->|Injected into| Api["ProxyProvider: ApiService"]
Api -->|Injected into| Repo["ProxyProvider: ProductRepository"]
Repo -->|Injected into| Model["ChangeNotifierProxyProvider: ProductModel"]Here’s an example implementation of a dependency chain using ProxyProvider:
MultiProvider(
providers: [
// 1. Define the main Source of Truth
ChangeNotifierProvider(create: (_) => AuthModel()),
// 2. Use ProxyProvider to inject the token from AuthModel into ApiService
ProxyProvider<AuthModel, ApiService>(
update: (BuildContext context, AuthModel auth, ApiService? previousApi) {
return ApiService(token: auth.token);
},
),
// 3. Use ChangeNotifierProxyProvider for reactive models needing dependencies
ChangeNotifierProxyProvider<ApiService, DashboardModel>(
create: (BuildContext context) => DashboardModel(),
update: (BuildContext context, ApiService api, DashboardModel? previousDashboard) {
return previousDashboard!..updateDependencies(api);
},
),
],
child: const MyApp(),
)
FutureProvider and StreamProvider — Easy Async Data #
The Provider library also provides two special types for delivering async data directly into the widget tree without repeatedly writing FutureBuilder or StreamBuilder widgets:
1. FutureProvider #
Very useful for one-time, time-consuming data loading operations (like loading configuration data from local storage).
FutureProvider<List<String>?>(
initialData: null,
create: (BuildContext context) => ConfigurationService.fetchFeatureList(),
child: const FeatureListScreen(),
)
2. StreamProvider #
Perfect for listening to real-time data flows, like internet connectivity or continuous Firebase database synchronization.
StreamProvider<ConnectionStatus>(
initialData: ConnectionStatus.connecting,
create: (BuildContext context) => ConnectionMonitor.statusStream,
child: const NetworkIndicatorWidget(),
)
Recommended Folder Structure #
To maintain team architecture order, it’s recommended to organize your state management folder structure neatly as follows:
lib/
├── main.dart
├── app_entry.dart
├── state/ # Central folder for data status management
│ ├── auth_notifier.dart
│ ├── theme_notifier.dart
│ └── cart_notifier.dart
├── services/ # External services folder (API, DB)
│ ├── api_service.dart
│ └── local_storage.dart
├── models/ # Pure data model folder (plain Dart)
│ ├── user.dart
│ └── product.dart
└── ui/ # Visual interface folder
├── screens/
└── widgets/
Complete Example — Authentication Page Implementation #
Let’s review a complete login authentication system implementation using Provider:
// state/auth_notifier.dart
class UserInfo {
final String name;
final String token;
const UserInfo({required this.name, required this.token});
}
class AuthNotifier extends ChangeNotifier {
UserInfo? _user;
bool _isLoading = false;
String? _errorMessage;
UserInfo? get user => _user;
bool get isLoggedIn => _user != null;
bool get isLoading => _isLoading;
String? get errorMessage => _errorMessage;
Future<void> login(String email, String password) async {
_isLoading = true;
_errorMessage = null;
notifyListeners();
try {
// Simulated server API call
await Future.delayed(const Duration(seconds: 2));
if (email == '[email protected]' && password == 'secret123') {
_user = const UserInfo(name: 'Ahmad Gani', token: 'jwt-token-abc');
} else {
throw Exception('Wrong email or password!');
}
} catch (e) {
_errorMessage = e.toString().replaceAll('Exception: ', '');
} finally {
_isLoading = false;
notifyListeners();
}
}
void logout() {
_user = null;
notifyListeners();
}
}
// ui/screens/login_screen.dart
class LoginScreen extends StatefulWidget {
const LoginScreen({super.key});
@override
State<LoginScreen> createState() => _LoginScreenState();
}
class _LoginScreenState extends State<LoginScreen> {
final TextEditingController _emailController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
@override
void dispose() {
_emailController.dispose();
_passwordController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final auth = context.watch<AuthNotifier>();
return Scaffold(
appBar: AppBar(title: const Text('Login Portal')),
body: Padding(
padding: const EdgeInsets.all(24.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
TextField(
controller: _emailController,
decoration: const InputDecoration(labelText: 'Email Address'),
),
const SizedBox(height: 12.0),
TextField(
controller: _passwordController,
obscureText: true,
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 24.0),
if (auth.errorMessage != null)
Text(
auth.errorMessage!,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 12.0),
auth.isLoading
? const CircularProgressIndicator()
: ElevatedButton(
onPressed: () {
context.read<AuthNotifier>().login(
_emailController.text,
_passwordController.text,
);
},
child: const Text('Login'),
),
],
),
),
);
}
}
Provider’s Limitations #
Although Provider is very popular and easy to use, you should understand several of its built-in limitations that later gave birth to the Riverpod library as an improvement:
- Absolute Dependence on BuildContext: You can’t read or modify data status outside the widget tree (e.g., inside independent service files) without forcibly passing a BuildContext.
- No Compile-time Safety: If you call
context.watch<ServiceA>()but forget to registerServiceAabove the widget tree, your app will suddenly crash at runtime with the error messageProviderNotFoundException. - Multiple Instantiation Difficulty: It’s very complicated to create several separate instances of the same provider type in the same widget tree.
The Riverpod library was specifically designed to answer and remove all the technical limitations above.
Summary #
- Provider simplifies
InheritedWidgetusage with automated resource disposal and reduced boilerplate.- Three Access Methods: Use
context.watch<T>()for rebuilds in build methods,context.read<T>()for non-reactive interaction inside callbacks, andcontext.select<T, R>()to limit rebuilds to only specific properties.- Consumer Widget: Enables static child cache insertion for interface render performance optimization.
- ProxyProvider: A tidy inter-model dependency chain solution (dependency injection).
- Scalability Plan: For medium-to-large apps with strict async error handling needs and isolated unit testing without the UI framework, consider switching to Riverpod.