NotifierProvider
This is Part 4 of Mastering Riverpod: Provider Types. We now cross from the read-only column of the provider grid into the mutable column. Every provider so far just exposed a value. But apps need to change state — increment a counter, toggle a setting, add a todo. The modern, recommended tool for synchronous mutable state is Notifier + NotifierProvider.
In Riverpod 3.0, Notifier is the way to hold mutable state — it replaces the older StateNotifier and StateProvider (now legacy, Part 7).
The shape: a class with build() and methods
A Notifier is a class that holds a piece of state and exposes methods to change it. Two required pieces:
- Extend
Notifier<T>whereTis your state type. - Override
build()to return the initial state.
Then add public methods that mutate state. Here's the canonical counter:
import 'package:flutter_riverpod/flutter_riverpod.dart';
class Counter extends Notifier<int> {
@override
int build() => 0; // the initial state
void increment() => state++; // a method that mutates state
void reset() => state = 0;
}
// The provider exposes the Notifier and its state.
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
Three things to read carefully:
build()returns the initial value (0). LikeProvider's create function, it runs lazily and canref.watchother providers.stateis a special property holding the current value. Assigning to it (state = ...) notifies listeners and rebuilds watchers — it's theNotifierequivalent ofsetState.NotifierProvider<Counter, int>(Counter.new)— two type args: the Notifier class and the state type.Counter.newis a tear-off (Dart functions-as-values) of the constructor.
Analogy: a
Notifieris like a FlutterStateobject (Flutter Part 3), but for app-wide state:build()is likeinitStatereturning the initial value,state =is likesetState, and methods are the actions. The difference: it lives in a provider, not a widget, so any widget can use it.
Reading the state and calling methods
This is where ref.watch vs ref.read from Foundation pays off. There are two things you read from a NotifierProvider:
- The state —
ref.watch(counterProvider)gives the currentint(and rebuilds on change). - The notifier —
ref.read(counterProvider.notifier)gives theCounterinstance, so you can call its methods.
class CounterScreen extends ConsumerWidget {
const CounterScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(counterProvider); // WATCH the state → rebuilds
return Scaffold(
body: Center(child: Text('$count')),
floatingActionButton: FloatingActionButton(
// READ the notifier in a callback → call its method
onPressed: () => ref.read(counterProvider.notifier).increment(),
child: const Icon(Icons.add),
),
);
}
}
The rule restated for notifiers:
ref.watch(provider)for the state (display, rebuild).ref.read(provider.notifier)for the notifier (call methods, in callbacks). The.notifiergives you the object; the plain provider gives you the value.
Never ref.watch(provider.notifier) just to call a method, and never call a method during build — both are the Foundation Part 5 mistakes applied to notifiers.
State must be immutable — replace, don't mutate
Here's the rule that trips up newcomers. You change state by replacing it (state = newValue), not by mutating it in place. Riverpod compares the old and new state (with ==) to decide whether to notify listeners — and mutating in place defeats that.
For primitives it's natural (state++, state = false). For collections and objects, you must build a new instance:
class TodoList extends Notifier<List<Todo>> {
@override
List<Todo> build() => [];
void add(Todo todo) {
// ❌ state.add(todo); // mutates in place — listeners may NOT update
state = [...state, todo]; // ✅ new list — triggers a rebuild
}
void remove(String id) {
state = state.where((t) => t.id != id).toList(); // ✅ new list
}
void toggle(String id) {
state = [
for (final t in state)
if (t.id == id) t.copyWith(done: !t.done) else t, // ✅ new objects
];
}
}
final todoListProvider = NotifierProvider<TodoList, List<Todo>>(TodoList.new);
Why immutability matters: Riverpod (3.0) filters updates by comparing old and new state with
==. If youstate.add(...)in place, the list identity doesn't change, so the comparison can decide "nothing changed" and skip the rebuild. Always assign a new value. (This is exactly the spread/copyWithstyle from the Dart collections and records/immutability material.)
The freezed package is popular precisely because it generates copyWith and == for immutable state classes — a perfect companion to Notifier (covered later in the Riverpod roadmap).
build() can depend on other providers
Like every provider, a Notifier's build() can ref.watch others, so its initial state is reactive:
class Filtered extends Notifier<List<Item>> {
@override
List<Item> build() {
final all = ref.watch(allItemsProvider); // re-runs build() if items change
final query = ref.watch(searchQueryProvider);
return all.where((i) => i.name.contains(query)).toList();
}
}
Note: inside a Notifier, ref is available as a property (no need to receive it as a parameter) — you can ref.watch/ref.read anywhere in the class. When a watched dependency changes, build() re-runs and recomputes the state.
When to use NotifierProvider
| Use NotifierProvider when… | Use something else when… |
| --- | --- |
| you have synchronous state that the UI mutates (counter, toggle, filter, in-memory todo list) | the state is read-only → Provider (Part 1) |
| you need methods (increment, add, toggle) | the initial state requires await → AsyncNotifierProvider (Part 5) |
| logic should live outside widgets, be testable | it's based on a Stream → StreamNotifierProvider (Part 6) |
The dividing line with Part 5: if
build()can return the state synchronously, useNotifier. Ifbuild()needs toawait(fetch from network/db before you have state), useAsyncNotifier.
Practice Challenges
Challenge 1 — Counter. Write a Counter notifier with increment/decrement and its provider.
Show solution
class Counter extends Notifier<int> {
@override
int build() => 0;
void increment() => state++;
void decrement() => state--;
}
final counterProvider = NotifierProvider<Counter, int>(Counter.new);
Challenge 2 — Use it. Display the count and wire a button to increment.
Show solution
final count = ref.watch(counterProvider); // state
// ...
onPressed: () => ref.read(counterProvider.notifier).increment(), // method
Challenge 3 — Immutable add. Why is state.add(todo) wrong, and what's right?
Show solution
state.add(todo) mutates the list in place; its identity doesn't change, so Riverpod's == comparison may skip notifying listeners (stale UI). Assign a new list: state = [...state, todo];.
Challenge 4 — Toggle a bool. Write a themeMode notifier exposing toggle().
Show solution
class ThemeModeN extends Notifier<bool> { // true = dark
@override
bool build() => false;
void toggle() => state = !state;
}
final isDarkProvider = NotifierProvider<ThemeModeN, bool>(ThemeModeN.new);
Challenge 5 — Sync or async? Initial state needs a DB read before it's known. Notifier or AsyncNotifier?
Show solution
AsyncNotifier (Part 5) — build() must await the DB, so it can't return synchronous state. Notifier is only for state you can produce immediately.
Questions to test yourself
Q1 (basic). What does a Notifier subclass override, and what does it return?
Show answer
It overrides build(), which returns the initial state (of type T for Notifier<T>). It also defines public methods that mutate state.
Q2 (basic). How do you change a Notifier's state?
Show answer
By assigning to the special state property (state = newValue / state++). Assigning notifies listeners and rebuilds watchers — like setState for app-wide state.
Q3 (intermediate). How do you read the state vs call a method from the UI?
Show answer
ref.watch(provider) reads the state (and rebuilds on change). ref.read(provider.notifier) gets the notifier instance to call its methods (in a callback). State → plain provider; methods → .notifier.
Q4 (intermediate). Why must Notifier state be replaced rather than mutated in place?
Show answer
Riverpod 3.0 filters updates by comparing old vs new state with ==. Mutating in place (e.g. state.add(...)) keeps the same object identity, so the comparison can conclude "no change" and skip rebuilding listeners. Assigning a new value guarantees the change is detected.
Q5 (intermediate). Where does the ref come from inside a Notifier?
Show answer
It's available as a property of the class (ref), so you can ref.watch/ref.read anywhere in the notifier without receiving ref as a parameter. build() can ref.watch other providers to make the state reactive.
Q6 (advanced). What's the precise dividing line between Notifier and AsyncNotifier?
Show answer
Whether build() can produce the state synchronously. If you have the initial state immediately (no awaiting), use Notifier<T> (state type T). If build() must await (e.g. fetch from network/db before the state exists), use AsyncNotifier<T> (Part 5), whose state is exposed as an AsyncValue<T>.
Wrapping up
NotifierProvider is the modern home for synchronous mutable state:
- A
Notifier<T>overridesbuild()(initial state) and exposes methods that changestate. - Assigning
state = ...notifies listeners and rebuilds watchers (likesetState, app-wide). - Read state with
ref.watch(provider), get the notifier withref.read(provider.notifier)to call methods. - Replace state immutably (
state = [...state, x]) — never mutate in place, or==-filtering may skip the rebuild. build()canref.watchother providers (refis a class property); it's for synchronous state.
But real mutable state is often async — you fetch a todo list from the server, then let users add/edit/delete it, each action with its own loading/error. Combining async loading with mutation is the job of Part 5: AsyncNotifierProvider — async state with full control.