Provider — Read-Only Values & DI
Welcome to Mastering Riverpod: Provider Types — the second series in the Riverpod journey (after Foundation). Riverpod gives you a small family of provider types, and the secret to using Riverpod well is knowing which one to reach for. This series goes through every one, a part each. We start with the simplest — plain Provider — but first, the map of the whole family.
If you haven't read the Foundation series, do that first — this series assumes
ProviderScope,ConsumerWidget, andref.watch/read/listen.
The 6 provider types, on one grid
Every Riverpod provider answers two questions: what kind of value does it expose (a plain value, a Future, or a Stream?) and can the outside world mutate it (read-only, or does it have methods to change state?). That's a 3×2 grid — and it's the entire family:
| | Read-only (no external mutation) | Mutable (has methods to change state) |
| --- | --- | --- |
| Sync value | Provider | NotifierProvider |
| Future | FutureProvider | AsyncNotifierProvider |
| Stream | StreamProvider | StreamNotifierProvider |
That grid is this series:
- Read-only column → the value is computed/fetched and you just read it:
Provider(this part),FutureProvider(Part 2),StreamProvider(Part 3). - Mutable column → you expose methods to change state:
NotifierProvider(Part 4),AsyncNotifierProvider(Part 5),StreamNotifierProvider(Part 6).
The decision rule: "Is it sync, a Future, or a Stream? And does something need to mutate it?" Answer those two and the grid hands you the provider. Keep this picture in your head for the whole series.
The async ones (Future/Stream rows) all expose their value wrapped in an AsyncValue (loading/data/error) — a concept so central it gets its own deep dive in Series 3. For now, the read-only sync case: Provider.
Plain Provider: the read-only workhorse
You met Provider in Foundation Part 4. Recap: it exposes a read-only, cached value computed by a (ref) => ... function that runs lazily on first read.
final piProvider = Provider<double>((ref) => 3.14159);
Provider is for values that don't change on their own from outside — there are no methods to mutate it. It's perfect for three jobs: computed/derived values, dependency injection, and combining other providers. Let's go deeper on each, since this is where Provider earns its keep in real apps.
Job 1: Computed / derived values
A Provider can derive its value from other providers using ref.watch. The derived value recomputes automatically when any dependency changes — and it's cached, so dependents don't recompute it themselves.
final cartItemsProvider = Provider<List<Item>>((ref) => [/* ... */]);
// Derived: total price, recomputed only when the cart changes.
final cartTotalProvider = Provider<double>((ref) {
final items = ref.watch(cartItemsProvider);
return items.fold(0.0, (sum, item) => sum + item.price);
});
This is a huge win: instead of recomputing the total in every widget that needs it, you compute it once in a provider and every widget ref.watches the cached result. Derive aggressively — small computed providers keep logic out of widgets and are trivially testable.
Recall the spreadsheet analogy from Foundation Part 1:
cartTotalProvideris the=SUM(...)cell. It recalculates itself when the cart changes; you never recompute it by hand.
Job 2: Dependency injection
The most common real-world use of Provider is exposing services and repositories as single shared instances, so the rest of the app depends on them without constructing them everywhere.
final dioProvider = Provider<Dio>((ref) => Dio()); // an HTTP client
final authRepositoryProvider = Provider<AuthRepository>((ref) {
final dio = ref.watch(dioProvider); // inject the client
return AuthRepository(dio);
});
final userRepositoryProvider = Provider<UserRepository>((ref) {
return UserRepository(ref.watch(dioProvider));
});
Three benefits, all from Foundation Part 3:
- Single shared instance — everyone gets the same
Dio/repository (cached). - Wiring is explicit — the dependency graph is right there in the code.
- Override for tests — swap any of these for a fake via
ProviderScope(overrides: [...]), no widget changes.
This layered DI (client → repository → … → UI) is the backbone of clean Riverpod architecture, and it all rides on plain Provider.
Job 3: Combining providers
Because a Provider can ref.watch several others, it's the natural place to combine state into a shape your UI wants:
final userProvider = Provider<User>((ref) => /* ... */);
final settingsProvider = Provider<Settings>((ref) => /* ... */);
// Combine into a view-model-ish object for one screen.
final dashboardProvider = Provider<DashboardData>((ref) {
final user = ref.watch(userProvider);
final settings = ref.watch(settingsProvider);
return DashboardData(user: user, theme: settings.theme);
});
The screen watches one dashboardProvider instead of juggling several — and the combination logic is testable in isolation.
When NOT to use plain Provider
Provider is read-only and synchronous. Reach for a different type when:
- The value comes from a
Future(a network/db call) →FutureProvider(Part 2). - The value comes from a
Stream(sockets, live updates) →StreamProvider(Part 3). - Something needs to mutate the state via methods (increment, add, toggle, submit) → a
Notifierfamily provider (Part 4 onward).
A quick smell test: if you ever wished Provider had a method like .increment() — you want a NotifierProvider. If you wished it could await — you want FutureProvider/AsyncNotifierProvider.
Practice Challenges
Challenge 1 — Place it on the grid. Which provider for: (a) a fixed app version string; (b) a number you increment on tap; (c) a one-time user fetch; (d) a live location stream?
Show solution
(a) Provider (sync, read-only). (b) NotifierProvider (sync, mutable). (c) FutureProvider (async, read-only). (d) StreamProvider (stream, read-only).
Challenge 2 — Derive a value. Given itemsProvider (a List<int>), write a countProvider and a sumProvider.
Show solution
final countProvider = Provider<int>((ref) => ref.watch(itemsProvider).length);
final sumProvider = Provider<int>((ref) =>
ref.watch(itemsProvider).fold(0, (a, b) => a + b));
Both recompute when itemsProvider changes, and are cached.
Challenge 3 — Inject a repository. Expose a Dio and a ProductRepository(dio).
Show solution
final dioProvider = Provider<Dio>((ref) => Dio());
final productRepoProvider = Provider<ProductRepository>(
(ref) => ProductRepository(ref.watch(dioProvider)),
);
Challenge 4 — Smell test. You wrote a Provider<int> and now want a toggle() method on it. What should it actually be?
Show solution
A NotifierProvider — Provider is read-only and has no mutation methods. The desire for a method like toggle()/increment() is the signal to use a Notifier (Part 4).
Questions to test yourself
Q1 (basic). What two questions does the provider-type grid ask?
Show answer
(1) What kind of value — sync value, Future, or Stream? (2) Is it read-only or mutable (does it expose methods to change state)? Those two axes pick the provider.
Q2 (basic). What does plain Provider expose, and is it mutable?
Show answer
A read-only, cached synchronous value computed by its (ref) => ... function. It has no methods to mutate it — for mutation you use a NotifierProvider.
Q3 (intermediate). Name the three main jobs of plain Provider.
Show answer
(1) Computed/derived values (via ref.watch of other providers). (2) Dependency injection (exposing shared service/repository instances). (3) Combining several providers into one shape for the UI.
Q4 (intermediate). Why derive a total in a Provider instead of computing it in each widget?
Show answer
The provider computes it once, caches it, and recomputes only when a dependency changes; every widget ref.watches the cached result. This avoids duplicate computation, keeps logic out of widgets, and is testable in isolation.
Q5 (intermediate). How does Provider-based DI interact with testing?
Show answer
Services/repositories are exposed as providers, so you can override them in a ProviderScope/ProviderContainer with fakes (Foundation Part 3). Production depends on the real implementation; tests transparently get the mock — no widget code changes.
Q6 (advanced). Give the "smell test" for when plain Provider is the wrong choice.
Show answer
If you wish Provider had a mutation method (.increment(), .add(), .toggle()), you actually want a NotifierProvider. If you wish it could await a value, you want FutureProvider/AsyncNotifierProvider; if it should react to a Stream, you want StreamProvider/StreamNotifierProvider. Plain Provider is only for read-only, synchronous values.
Wrapping up
The provider family is a 3×2 grid, and Provider is its read-only-sync corner:
- The grid: sync / Future / Stream × read-only / mutable = the 6 provider types.
Providerexposes a read-only, cached, synchronous value.- Its three jobs: derived values, dependency injection (services/repos), and combining providers.
- Switch types when the value is a Future (
FutureProvider), a Stream (StreamProvider), or needs mutation (Notifierfamily).
Most real data isn't sitting in memory — it's behind a network or database call. For that, you need a provider that can await. Part 2 is FutureProvider — handling async data the clean way, and it introduces the AsyncValue that powers all of Riverpod's async story.