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 of ChangeNotifierProvider is that it automatically calls the dispose() method on the CartModel object 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 field onChanged, or inside initial initState initialization.
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 placing Consumer at 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(),
)

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:

  1. 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.
  2. No Compile-time Safety: If you call context.watch<ServiceA>() but forget to register ServiceA above the widget tree, your app will suddenly crash at runtime with the error message ProviderNotFoundException.
  3. 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 InheritedWidget usage 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, and context.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.

← Previous: setState & ValueNotifier   Next: Riverpod →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact