← Back to blog
Mastering Riverpod: Provider Types · Part 4 of 8
August 11, 20268 min read

NotifierProvider — The Modern Replacement for StateNotifier

RiverpodFlutterDart

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:

  1. Extend Notifier<T> where T is your state type.
  2. 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). Like Provider's create function, it runs lazily and can ref.watch other providers.
  • state is a special property holding the current value. Assigning to it (state = ...) notifies listeners and rebuilds watchers — it's the Notifier equivalent of setState.
  • NotifierProvider<Counter, int>(Counter.new) — two type args: the Notifier class and the state type. Counter.new is a tear-off (Dart functions-as-values) of the constructor.

Analogy: a Notifier is like a Flutter State object (Flutter Part 3), but for app-wide state: build() is like initState returning the initial value, state = is like setState, 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 stateref.watch(counterProvider) gives the current int (and rebuilds on change).
  • The notifierref.read(counterProvider.notifier) gives the Counter instance, 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 .notifier gives 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 you state.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/copyWith style 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-onlyProvider (Part 1) | | you need methods (increment, add, toggle) | the initial state requires awaitAsyncNotifierProvider (Part 5) | | logic should live outside widgets, be testable | it's based on a StreamStreamNotifierProvider (Part 6) |

The dividing line with Part 5: if build() can return the state synchronously, use Notifier. If build() needs to await (fetch from network/db before you have state), use AsyncNotifier.


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> overrides build() (initial state) and exposes methods that change state.
  • Assigning state = ... notifies listeners and rebuilds watchers (like setState, app-wide).
  • Read state with ref.watch(provider), get the notifier with ref.read(provider.notifier) to call methods.
  • Replace state immutably (state = [...state, x]) — never mutate in place, or ==-filtering may skip the rebuild.
  • build() can ref.watch other providers (ref is 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.