Advanced State Management (Bloc & Riverpod)

Riverpod Notifiers: StateNotifier and NotifierProvider

16 min Lesson 8 of 14

Riverpod Notifiers: StateNotifier and NotifierProvider

As your Riverpod-managed features grow in complexity, raw StateProvider becomes too limited — it exposes the mutable state directly and mixes business logic into the UI. StateNotifier and its modern successor Notifier solve this by encapsulating both the state and the operations that may change it inside a dedicated class, while widgets only ever receive an immutable snapshot of that state.

Architecture note: Think of a notifier as a tiny, focused ViewModel or store. It owns the state, exposes methods that describe business operations, and the UI consumes the read-only state through NotifierProvider (or StateNotifierProvider). Widgets never mutate state directly.

StateNotifier — the Classic API

StateNotifier<S> from the state_notifier package (re-exported by Riverpod) is a class that:

  • Holds a single immutable state of type S.
  • Exposes methods to produce a new state (you replace state, never mutate it).
  • Notifies listeners automatically whenever state is reassigned.
  • Is registered with StateNotifierProvider so any widget can watch it.

StateNotifier — Shopping Cart Example

import 'package:flutter_riverpod/flutter_riverpod.dart';

// 1. Define an immutable state class
class CartState {
  final List<String> items;
  final bool isLoading;

  const CartState({this.items = const [], this.isLoading = false});

  // copyWith pattern keeps mutations safe
  CartState copyWith({List<String>? items, bool? isLoading}) {
    return CartState(
      items: items ?? this.items,
      isLoading: isLoading ?? this.isLoading,
    );
  }
}

// 2. Extend StateNotifier with your business logic
class CartNotifier extends StateNotifier<CartState> {
  CartNotifier() : super(const CartState());

  void addItem(String product) {
    // Always create a NEW state — never mutate the existing list in place
    state = state.copyWith(items: [...state.items, product]);
  }

  void removeItem(String product) {
    state = state.copyWith(
      items: state.items.where((i) => i != product).toList(),
    );
  }

  Future<void> checkout() async {
    state = state.copyWith(isLoading: true);
    await Future.delayed(const Duration(seconds: 2)); // simulate network
    state = const CartState(); // reset after checkout
  }
}

// 3. Expose the notifier through a provider
final cartProvider = StateNotifierProvider<CartNotifier, CartState>(
  (ref) => CartNotifier(),
);

Consuming StateNotifierProvider in the UI

Widgets use ref.watch to subscribe to the state and ref.read (inside callbacks) to call methods on the notifier. The notifier instance is accessed via the .notifier modifier.

Widget That Reads and Drives a StateNotifier

class CartScreen extends ConsumerWidget {
  const CartScreen({super.key});

  @override
  Widget build(BuildContext context, WidgetRef ref) {
    // watch() subscribes — rebuilds when CartState changes
    final cart = ref.watch(cartProvider);

    return Scaffold(
      appBar: AppBar(title: Text('Cart (${cart.items.length})')),
      body: cart.isLoading
          ? const Center(child: CircularProgressIndicator())
          : ListView(
              children: cart.items
                  .map((item) => ListTile(
                        title: Text(item),
                        trailing: IconButton(
                          icon: const Icon(Icons.remove_circle),
                          onPressed: () => ref
                              .read(cartProvider.notifier)
                              .removeItem(item),
                        ),
                      ))
                  .toList(),
            ),
      floatingActionButton: FloatingActionButton(
        onPressed: () =>
            ref.read(cartProvider.notifier).addItem('New Product'),
        child: const Icon(Icons.add),
      ),
    );
  }
}
Tip: Use ref.watch(provider) inside build to get reactive state. Use ref.read(provider.notifier) inside callbacks and event handlers — reading (not watching) during an event avoids accidental rebuilds triggered by the read itself.

The Modern Notifier API (Riverpod 2.x)

Riverpod 2 introduced Notifier<S> and NotifierProvider as the recommended replacement for StateNotifier. Key differences:

  • The notifier class extends Notifier<S> instead of StateNotifier<S>.
  • The initial state is returned from the required build() method — making it easy for code generation (riverpod_generator) to work with it.
  • The notifier has direct access to ref via this.ref, so it can read other providers without constructor injection.
  • Registered with NotifierProvider<NotifierClass, StateType>.

Same Cart — Rewritten with the Notifier API

import 'package:flutter_riverpod/flutter_riverpod.dart';

// Same immutable CartState as before (reuse the copyWith pattern)

class CartNotifier2 extends Notifier<CartState> {
  @override
  CartState build() {
    // build() replaces the constructor super() call
    return const CartState();
  }

  void addItem(String product) {
    state = state.copyWith(items: [...state.items, product]);
  }

  void removeItem(String product) {
    state = state.copyWith(
      items: state.items.where((i) => i != product).toList(),
    );
  }

  Future<void> checkout() async {
    state = state.copyWith(isLoading: true);
    // Access other providers through ref without passing them in
    // final user = ref.read(currentUserProvider);
    await Future.delayed(const Duration(seconds: 2));
    state = const CartState();
  }
}

// NotifierProvider — new style
final cartProvider2 = NotifierProvider<CartNotifier2, CartState>(
  CartNotifier2.new,
);
Important: The state setter in both APIs triggers a rebuild only if the new value is not identical to the old one (using ==). If your state class does not override ==, Dart compares by reference — so always create a new instance (via copyWith or a fresh constructor call) rather than mutating fields in place.

Async Notifier (AsyncNotifier)

When your state involves asynchronous data (API calls, local database reads), use AsyncNotifier<S> with AsyncNotifierProvider. The build() method becomes Future<S> build(), and state is automatically wrapped in AsyncValue<S> for free loading/error/data handling.

Choosing Between StateNotifier and Notifier

  • New projects: Prefer Notifier / NotifierProvider — it is the officially recommended API in Riverpod 2.x and integrates cleanly with riverpod_generator.
  • Existing codebases: StateNotifierProvider continues to work; there is no urgency to migrate. Both APIs are fully supported.
  • Async operations: Use AsyncNotifier / AsyncNotifierProvider to get automatic AsyncValue wrapping.

Summary

StateNotifier and the newer Notifier API are the primary tools for encapsulating mutable state and business logic in Riverpod. Both follow the same pattern: an immutable state class (often with copyWith), a notifier class that exposes named business-operation methods, and a provider that wires them into the widget tree. Widgets remain pure: they watch the read-only state and invoke notifier methods — never touching state directly.

Key Takeaway: Prefer immutable state objects with copyWith. Use StateNotifier for legacy/compatibility and Notifier for new Riverpod 2 code. Access methods via ref.read(provider.notifier) and subscribe to state via ref.watch(provider).