Ana içeriğe atla

From `StateNotifier`

Along with Riverpod 2.0, new classes were introduced: Notifier / AsyncNotifer.
StateNotifier is now discouraged in favor of those new APIs.

This page shows how to migrate from the deprecated StateNotifier to the new APIs.

The main benefit introduced by AsyncNotifier is a better async support; indeed, AsyncNotifier can be thought as a FutureProvider which can expose ways to be modified from the UI..

Furthermore, the new (Async)Notifiers:

  • Expose a Ref object inside its class
  • Offer similar syntax between codegen and non-codegen approaches
  • Offer similar syntax between their sync and async versions
  • Move away logic from Providers and centralize it into the Notifiers themselves

Let's see how to define a Notifier, how it compares with StateNotifier and how to migrate the new AsyncNotifier for asynchronous state.

New syntax comparison

Be sure to know how to define a Notifier before diving into this comparison. See Performing side effects.

Let's write an example, using the old StateNotifier syntax:

class CounterNotifier extends StateNotifier<int> {
CounterNotifier() : super(0);

void increment() => state++;
void decrement() => state--;
}

final counterNotifierProvider = StateNotifierProvider<CounterNotifier, int>((ref) {
return CounterNotifier();
});

Here's the same example, built with the new Notifier APIs, which roughly translates to:


class CounterNotifier extends _$CounterNotifier {

int build() => 0;

void increment() => state++;
void decrement() => state--;
}

Comparing Notifier with StateNotifier, one can observe these main differences:

  • StateNotifier's reactive dependencies are declared in its provider, whereas Notifier centralizes this logic in its build method
  • StateNotifier's whole initialization process is split between its provider and its constructor, whereas Notifier reserves a single place to place such logic
  • Notice how, as opposed to StateNotifier, no logic is ever written into a Notifier's constructor

Similar conclusions can be made with AsyncNotifer, Notifier's asynchronous equivalent.

Migrating asynchronous StateNotifiers

The main appeal of the new API syntax is an improved DX on asynchronous data.
Take the following example:

class AsyncTodosNotifier extends StateNotifier<AsyncValue<List<Todo>>> {
AsyncTodosNotifier() : super(const AsyncLoading()) {
_postInit();
}

Future<void> _postInit() async {
state = await AsyncValue.guard(() async {
final json = await http.get('api/todos');

return [...json.map(Todo.fromJson)];
});
}

// ...
}

Here's the above example, rewritten with the new AsyncNotifier APIs:


class AsyncTodosNotifier extends _$AsyncTodosNotifier {

FutureOr<List<Todo>> build() async {
final json = await http.get('api/todos');

return [...json.map(Todo.fromJson)];
}

// ...
}

AsyncNotifer, just like Notifier, brings a simpler and more uniform API. Here, it's easy to see AsyncNotifer as a FutureProvider with methods.

AsyncNotifer comes with a set of utilities and getters that StateNotifier doesn't have, such as e.g. future and update. This enables us to write much simpler logic when handling asynchronous mutations and side-effects. See also Performing side effects.

ipucu

Migrating from StateNotifier<AsyncValue<T>> to a AsyncNotifer<T> boils down to:

  • Putting initialization logic into build
  • Removing any catch/try blocks in initialization or in side effects methods
  • Remove any AsyncValue.guard from build, as it converts Futures into AsyncValues

Advantages

After these few examples, let's now highlight the main advantages of Notifier and AsyncNotifer:

  • The new syntax should feel way simpler and more readable, especially for asynchronous state
  • New APIs are likely to have less boilerplate code in general
  • Syntax is now unified, no matter the type of provider you're writing, enabling code generation (see About code generation)

Let's go further down and highlight more differences and similarities.

Explicit .family and .autoDispose modifications

Another important difference is how families and auto dispose is handled with the new APIs.

Notifier, has its own .family and .autoDispose counterparts, such as FamilyNotifier and AutoDisposeNotifier.
As always, such modifications can be combined (aka AutoDisposeFamilyNotifier).
AsyncNotifer has its asynchronous equivalent, too (e.g. AutoDisposeFamilyAsyncNotifier).

Modifications are explicitly stated inside the class; any parameters are directly injected in the build method, so that they're available to the initialization logic.
This should bring better readability, more conciseness and overall less mistakes.

Take the following example, in which a StateNotifierProvider.family is being defined.

class BugsEncounteredNotifier extends StateNotifier<AsyncValue<int>> {
BugsEncounteredNotifier({
required this.ref,
required this.featureId,
}) : super(const AsyncData(99));
final String featureId;
final Ref ref;

Future<void> fix(int amount) async {
state = await AsyncValue.guard(() async {
final old = state.requireValue;
final result = await ref.read(taskTrackerProvider).fix(id: featureId, fixed: amount);
return max(old - result, 0);
});
}
}

final bugsEncounteredNotifierProvider =
StateNotifierProvider.family.autoDispose<BugsEncounteredNotifier, int, String>((ref, id) {
return BugsEncounteredNotifier(ref: ref, featureId: id);
});

BugsEncounteredNotifier feels... heavy / hard to read.
Let's take a look at its migrated AsyncNotifier counterpart:


class BugsEncounteredNotifier extends _$BugsEncounteredNotifier {

FutureOr<int> build(String featureId) {
return 99;
}

Future<void> fix(int amount) async {
final old = await future;
final result = await ref.read(taskTrackerProvider).fix(id: this.featureId, fixed: amount);
state = AsyncData(max(old - result, 0));
}
}

Its migrated counterpart should feel like a light read.

bilgi

(Async)Notifier's .family parameters are available via this.arg (or this.paramName when using codegen)

Lifecycles have a different behavior

Lifecycles between Notifier/AsyncNotifier and StateNotifier differ substantially.

This example showcases - again - how the old API have sparse logic:

class MyNotifier extends StateNotifier<int> {
MyNotifier(this.ref, this.period) : super(0) {
// 1 init logic
_timer = Timer.periodic(period, (t) => update()); // 2 side effect on init
}
final Duration period;
final Ref ref;
late final Timer _timer;

Future<void> update() async {
await ref.read(repositoryProvider).update(state + 1); // 3 mutation
if (mounted) state++; // 4 check for mounted props
}


void dispose() {
_timer.cancel(); // 5 custom dispose logic
super.dispose();
}
}

final myNotifierProvider = StateNotifierProvider<MyNotifier, int>((ref) {
// 6 provider definition
final period = ref.watch(durationProvider); // 7 reactive dependency logic
return MyNotifier(ref, period); // 8 pipe down `ref`
});

Here, if durationProvider updates, MyNotifier disposes: its instance is then re-instantiated and its internal state is then re-initialized.
Furthermore, unlike every other provider, the dispose callback is to be defined in the class, separately.
Finally, it is still possible to write ref.onDispose in its provider, showing once again how sparse the logic can be with this API; potentially, the developer might have to look into eight (8!) different places to understand this Notifier behavior!

These ambiguities are solved with Riverpod 2.0.

Old dispose vs ref.onDispose

StateNotifier's dispose method refers to the dispose event of the notifier itself, aka it's a callback that gets called before disposing of itself.

(Async)Notifiers don't have this property, since they don't get disposed of on rebuild; only their internal state is.
In the new notifiers, dispose lifecycles are taken care of in only one place, via ref.onDispose (and others), just like any other provider. This simplifies the API, and hopefully the DX, so that there is only one place to look at to understand lifecycle side-effects: its build method.

Shortly: to register a callback that fires before its internal state rebuilds, we can use ref.onDispose like every other provider.

You can migrate the above snippet like so:


class MyNotifier extends _$MyNotifier {

int build() {
// Just read/write the code here, in one place
final period = ref.watch(durationProvider);
final timer = Timer.periodic(period, (t) => update());
ref.onDispose(timer.cancel);

return 0;
}

Future<void> update() async {
await ref.read(repositoryProvider).update(state + 1);
// `mounted` is no more!
state++; // This might throw.
}
}

In this last snippet there sure is some simplification, but there's still an open problem: we are now unable to understand whether or not our notifiers are still alive while performing update.
This might arise an unwanted StateErrors.

No more mounted

This happens because (Async)Notifiers lacks a mounted property, which was available on StateNotifier.
Considering their difference in lifecycle, this makes perfect sense; while possible, a mounted property would be misleading on the new notifiers: mounted would almost always be true.

While it would be possible to craft a custom workaround, it's recomended to work around this by canceling the asynchronous operation.

Canceling an operation can be done with a custom Completer, or any custom derivative.

For example, if you're using Dio to perform network requests, consider using a cancel token (see also Clearing cache and reacting to state disposal).

Therefore, the above example migrates to the following:


class MyNotifier extends _$MyNotifier {

int build() {
// Just read/write the code here, in one place
final period = ref.watch(durationProvider);
final timer = Timer.periodic(period, (t) => update());
ref.onDispose(timer.cancel);

return 0;
}

Future<void> update() async {
final cancelToken = CancelToken();
ref.onDispose(cancelToken.cancel);
await ref.read(repositoryProvider).update(state + 1, token: cancelToken);
// When `cancelToken.cancel` is invoked, a custom Exception is thrown
state++;
}
}

Mutations APIs are the same as before

Up until now we've shown the differences between StateNotifier and the new APIs.
Instead, one thing Notifier, AsyncNotifer and StateNotifier share is how their states can be consumed and mutated.

Consumers can obtain data from these three providers with the same syntax, which is great in case you're migrating away from StateNotifier; this applies for notifiers methods, too.

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


Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterNotifierProvider);
return Column(
children: [
Text("You've counted up until $counter, good job!"),
TextButton(
onPressed: ref.read(counterNotifierProvider.notifier).increment,
child: const Text('Count even more!'),
)
],
);
}
}

Other migrations

Let's explore the less-impactful differences between StateNotifier and Notifier (or AsyncNotifier)

From .addListener and .stream

StateNotifier's .addListener and .stream can be used to listen for state changes. These two APIs are now to be considered outdated.

This is intentional due to the desire to reach full API uniformity with Notifier, AsyncNotifier and other providers.
Indeed, using a Notifier or an AsyncNotifier shouldn't be any different from any other provider.

Therefore this:

class MyNotifier extends StateNotifier<int> {
MyNotifier() : super(0);

void add() => state++;
}

final myNotifierProvider = StateNotifierProvider<MyNotifier, int>((ref) {
final notifier = MyNotifier();

final cleanup = notifier.addListener((state) => debugPrint('$state'));
ref.onDispose(cleanup);

// Or, equivalently:
// final listener = notifier.stream.listen((event) => debugPrint('$event'));
// ref.onDispose(listener.cancel);

return notifier;
});

Becomes this:


class MyNotifier extends _$MyNotifier {

int build() {
listenSelf((_, next) => debugPrint('$next'));
return 0;
}

void add() => state++;
}

In a nutshell: if you want to listen to a Notifier/AsyncNotifer, just use ref.listen. See Combining requests.

From .debugState in tests

StateNotifier exposes .debugState: this property is used for pkg:state_notifier users to enable state access from outside the class when in development mode, for testing purposes.

If you're using .debugState to access state in tests, chances are that you need to drop this approach.

Notifier / AsyncNotifer don't have a .debugState; instead, they directly expose .state, which is @visibleForTesting.

tehlike

AVOID accessing .state from tests; if you have to, do it if and only if you had already have a Notifier / AsyncNotifer properly instantied; then, you could access .state inside tests freely.

Indeed, Notifier / AsyncNotifier should not be instantiated by hand; instead, they should be interacted with by using its provider: failing to do so will break the notifier, due to ref and family args not being initialized.

Don't have a Notifier instance?
No problem, you can obtain one with ref.read, just like you would read its exposed state:

void main(List<String> args) {
test('my test', () {
final container = ProviderContainer();
addTearDown(container.dispose);

// Obtaining a notifier
final AutoDisposeNotifier<int> notifier = container.read(myNotifierProvider.notifier);

// Obtaining its exposed state
final int state = container.read(myNotifierProvider);

// TODO write your tests
});
}

Learn more about testing in its dedicated guide. See Testing your providers.

From StateProvider

StateProvider was exposed by Riverpod since its release, and it was made to save a few LoC for simplified versions of StateNotifierProvider.
Since StateNotifierProvider is deprecated, StateProvider is to be avoided, too.
Furthermore, as of now, there is no StateProvider equivalent for the new APIs.

Nonetheless, migrating from StateProvider to Notifier is simple.

This:

final counterProvider = StateProvider<int>((ref) {
return 0;
});

Becomes:


class CounterNotifier extends _$CounterNotifier {

int build() => 0;


set state(int newState) => super.state = newState;
int update(int Function(int state) cb) => state = cb(state);
}

Even though it costs us a few more LoC, migrating away from StateProvider enables us to definetively archive StateNotifier.