Skip to content
ExponaBLOG
FLUTTER · DAY 026 / 100

Provider basics: what every Flutter developer should know

The simplest step up from setState.

Expona Admin7 min read

Provider basics

You've built a few Flutter apps with setState, and you're tired of passing callbacks down three levels of widgets just to update a counter. Provider basics is the simplest step up—it lets you manage state at the top of your widget tree and access it from anywhere below without drilling props through every layer. After day 26, you'll understand why Provider is the default choice for thousands of production apps, and why it's often all you'll ever need.

Why Provider, why now?

By day 26 of your Flutter journey, you've likely hit the ceiling with setState. Every callback becomes a chain, every shared value requires a parent widget, and your widget tree starts to look like a spaghetti mess. Provider solves this by introducing a clean, simple pattern: you declare your state once, and any descendant widget can read or watch it. No ceremony, no boilerplate, no Riverpod or Bloc magic—just pragmatic dependency injection that works.

I've used Provider in production for ride-hailing and e-commerce platforms where multiple screens needed to react to authentication, cart state, and real-time updates simultaneously. It scaled beautifully without forcing me into Redux-style overhead early on.

Provide a value — Put an object above the widgets that need it

The first concept is straightforward: you wrap a subtree of your app with ChangeNotifierProvider, passing a model that holds state. Any widget below that provider can then access it. The provider stays at the top; the state flows down.

Let's say you're building a simple counter app. Create a counter model first:

dart
import 'package:flutter/material.dart';

class CounterModel extends ChangeNotifier {
  int _count = 0;

  int get count => _count;

  void increment() {
    _count++;
    notifyListeners();
  }
}

Now, in your main.dart, wrap your app (or the relevant subtree) with the provider:

dart
void main() {
  runApp(
    ChangeNotifierProvider(
      create: (context) => CounterModel(),
      child: const MyApp(),
    ),
  );
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      home: const CounterScreen(),
    );
  }
}

That's it. Your CounterModel is now available to every widget under MyApp. The beauty is that you're not passing it as a constructor argument—it's just there, waiting to be accessed.

In a real production app—say a travel booking platform—your providers might sit above your root navigator and expose user session data, booking history, and payment methods. Every booking screen, every confirmation page, every settings panel can tap into these without plumbing.

Read or watch — context.watch rebuilds on change; context.read doesn't

Now that your state is provided, you need to access it. This is where Provider's elegance shines: you have two options, and choosing the right one is critical.

context.watch — Rebuild on change

Use context.watch() when your widget needs to react and rebuild whenever the provider's value changes. It's the equivalent of listening to a stream in old code patterns.

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

  @override
  Widget build(BuildContext context) {
    // This entire widget rebuilds when CounterModel notifies listeners
    final counter = context.watch();

    return Scaffold(
      appBar: AppBar(title: const Text('Counter')),
      body: Center(
        child: Text(
          'Count: ${counter.count}',
          style: Theme.of(context).textTheme.displayLarge,
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () => counter.increment(),
        child: const Icon(Icons.add),
      ),
    );
  }
}

Every time increment() is called, notifyListeners() fires, and CounterScreen rebuilds to show the new count. Simple and direct.

context.read — Access without listening

Use context.read() when you just need to call a method or fetch a value once, without setting up a listener. It's cheaper and cleaner when you don't need reactivity.

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

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        // We only read the value to trigger an action, not to rebuild
        final counter = context.read();
        print('Final count: ${counter.count}');
        // Navigate or submit data
      },
      child: const Text('Submit'),
    );
  }
}

In production, I use context.read() a lot in callback handlers: when a user taps "Book Now," I read the cart state once, validate it, and send it to the backend. I don't need the button to rebuild when the cart changes—I just need the current snapshot.

The performance difference

This distinction matters. If you use context.watch() everywhere, you'll rebuild widgets unnecessarily. If you overuse context.read(), you'll miss updates you should have reacted to. Think of it this way:

  • watch = "Subscribe to updates and rebuild when they arrive."
  • read = "Give me the current state, no subscription."

Combine providers — MultiProvider for several dependencies

Real apps rarely have just one provider. You'll have user state, cart state, theme state, maybe a WebSocket connection manager. Nesting them all is messy:

dart
// Don't do this
ChangeNotifierProvider(
  create: (_) => UserModel(),
  child: ChangeNotifierProvider(
    create: (_) => CartModel(),
    child: ChangeNotifierProvider(
      create: (_) => ThemeModel(),
      child: MyApp(),
    ),
  ),
)

Instead, use MultiProvider:

dart
void main() {
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => UserModel()),
        ChangeNotifierProvider(create: (_) => CartModel()),
        ChangeNotifierProvider(create: (_) => ThemeModel()),
        ChangeNotifierProvider(create: (_) => NetworkModel()),
      ],
      child: const MyApp(),
    ),
  );
}

Much cleaner. And since you'll eventually reach for these providers from different screens, you can nest MultiProvider at the root level. Your entire app sees all of them.

In my ride-hailing platform, I had around eight providers at the root: user session, ride history, active ride tracking, notification queue, payment method, device location, chat messages, and a connection pool. MultiProvider made it elegant and testable.

Good starting point — Many apps never need more

This is the secret many developers miss: Provider is often enough. You don't need Redux, Bloc, Riverpod, or GetX unless you're building a complex, highly scaled app with intricate state logic. For most CRUD apps, dashboards, marketplace features, and even real-time updates, Provider + ChangeNotifier is sufficient.

I've deployed production apps with 50+ screens, complex WebSocket integration, and real-time data synchronization using Provider alone. It stayed performant because:

  • I was intentional about using watch vs. read.
  • I kept models small and focused (one model = one responsibility).
  • I used ChangeNotifier correctly—calling notifyListeners() only when state actually changed.

The moment Provider feels limiting is when you need:

  • Complex derived state (computed values from multiple sources).
  • Async operations with built-in caching and loading states.
  • Family-style parameterized providers (dynamic instances based on arguments).
  • Time-travel debugging or devtools integration.

Then you graduate to Riverpod. But that's a day-30+ conversation.

Common mistakes

Mistake 1: Rebuilding too much by overusing watch. If you watch a provider in a widget that doesn't actually display its data, you'll rebuild unnecessarily. Use read in callbacks, and only watch where the UI depends on the value.

Mistake 2: Calling notifyListeners() excessively. Every call triggers rebuilds. If you're looping through a list of items and calling notifyListeners() after each mutation, batch them instead:

dart
// Bad
for (var item in items) {
  _list.add(item);
  notifyListeners(); // Called 100 times
}

// Good
for (var item in items) {
  _list.add(item);
}
notifyListeners(); // Called once

Mistake 3: Mixing business logic into the provider. Your CounterModel should manage state; HTTP calls, file I/O, and complex calculations belong in services. Keep models thin.

Mistake 4: Accessing providers outside of build() or callbacks in weird ways. Always use context.watch() or context.read() in the right context. Don't stash a reference and mutate it elsewhere.

Key takeaways

  • Provider basics means wrapping your app with a ChangeNotifierProvider to make state accessible to any descendant widget without drilling.
  • Use context.watch() when your widget's UI depends on the provider's value and should rebuild when it changes.
  • Use context.read() when you just need the current value for a one-time operation, like a button press or validation.
  • Combine multiple providers with MultiProvider at your app root for a flat, readable structure.
  • Provider + ChangeNotifier is sufficient for most production apps; only move to Riverpod or Bloc if you hit genuine scaling limits.
  • Optimize performance by batching notifyListeners() calls and being deliberate about watch vs. read.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles