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)Notifier
s:
- 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, whereasNotifier
centralizes this logic in itsbuild
methodStateNotifier
's whole initialization process is split between its provider and its constructor, whereasNotifier
reserves a single place to place such logic- Notice how, as opposed to
StateNotifier
, no logic is ever written into aNotifier
's constructor
Similar conclusions can be made with AsyncNotifer
, Notifier
's asynchronous equivalent.
Migrating asynchronous StateNotifier
s
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.
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
frombuild
, as it convertsFuture
s intoAsyncValue
s
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.
(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)Notifier
s 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 StateError
s.
No more mounted
This happens because (Async)Notifier
s 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
.
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
.