跳到主要内容

What's new in Riverpod 3.0

Welcome to Riverpod 3.0!
This update includes many long-due features, bug fixes, and simplifications of the API.

This version is a transition period toward a simpler, unified Riverpod.

警告

This version contains a few life-cycle changes. Those could break your app in subtle ways. Upgrade carefully.
For the migration guide, please refer to the migration page.

Some of the key highlights include:

Offline persistence (experimental)

信息

This feature is experimental and not yet stable. It is usable, but the API may change in breaking ways without a major version bump.

Offline persistence is a new feature that enables caching a provider locally on the device. Then, when the application is closed and reopened, the provider can be restored from the cache.
Offline persistence is opt-in, and supported by all "Notifier" providers, and regardless of if you use code generation or not.

Riverpod only includes interfaces to interact with a database. It does not include a database itself. You can use any database you want, as long as it implements the interfaces.
An official package for SQlife is maintained: riverpod_sqflite.

As a short demo, here's how you can use offline persistence:


Future<JsonSqFliteStorage> storage(Ref ref) async {
// Initialize SQFlite. We should share the Storage instance between providers.
return JsonSqFliteStorage.open(
join(await getDatabasesPath(), 'riverpod.db'),
);
}

/// A serializable Todo class. We're using Freezed for simple serialization.

abstract class Todo with _$Todo {
const factory Todo({
required int id,
required String description,
required bool completed,
}) = _Todo;

factory Todo.fromJson(Map<String, dynamic> json) => _$TodoFromJson(json);
}


()
class TodosNotifier extends _$TodosNotifier {

FutureOr<List<Todo>> build() async {
// We call persist at the start of our 'build' method.
// This will:
// - Read the DB and update the state with the persisted value the first
// time this method executes.
// - Listen to changes on this provider and write those changes to the DB.
// We "await" for persist to complete to make sure that the decoding is done
// before we return the state.
await persist(
// We pass our JsonSqFliteStorage instance. No need to "await" the Future.
// Riverpod will take care of that.
storage: ref.watch(storageProvider.future),
// By default, state is cached offline only for 2 days.
// In this example, we tell Riverpod to cache the state forever.
options: const StorageOptions(cacheTime: StorageCacheTime.unsafe_forever),
);

// If a state is persisted, we return it. Otherwise we return an empty list.
return state.value ?? [];
}

Future<void> add(Todo todo) async {
// When modifying the state, no need for any extra logic to persist the change.
// Riverpod will automatically cache the new state and write it to the DB.
state = AsyncData([...await future, todo]);
}
}

Mutations (experimental) (code-generation only)

信息

This feature is experimental and not yet stable. It is usable, but the API may change in breaking ways without a major version bump.

备注

Mutations are currently only available using code generation.
Support for non-code generation is planned.

A new feature called "mutations" is introduced in Riverpod 3.0.
This feature is a mean to allow your UI to easily show spinners/errors for side-effects (such as form submissions).

The TL;DR is, inside a Notifier, you can annotate a method with @mutation:


class Todos extends _$Todos {

Future<List<Todo>> build() async {
// TODO: fetch todos from the server
return [];
}


Future<void> addTodo(String title) async {
// Post the new todo to the server
await http.post('https://example.com/todos', body: {
'title': title,
});
// Refresh the state after the post request
ref.invalidateSelf();
}
}

After that, your UI can use ref.listen/ref.watch to listen to the state of mutations:

class AddTodoButton extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
// Listen to the status of the "addTodo" side-effect
final addTodo = ref.watch(todosProvider.addTodo);

return switch (addTodo.state) {
// No side-effect is in progress
// Let's show a submit button
MutationIdle() => ElevatedButton(
// Trigger the side-effect on click. This is done by calling
// the "addTodo" object and passing the required parameters.
onPressed: () => addTodo('New Todo'),
child: const Text('Submit'),
),
// The side-effect is in progress. We show a spinner
MutationPending() => const CircularProgressIndicator(),
// The side-effect failed. We show a retry button
MutationErrored() => ElevatedButton(
onPressed: () => addTodo('New Todo'),
child: const Text('Retry'),
),
// The side-effect was successful. We show a success message
MutationSucceeded() => const Text('Todo added!'),
};
}
}

Automatic retry

Starting 3.0, providers that fail during initialization will automatically retry. The retry is done with an exponential backoff, and the provider will be retried until it succeeds or is disposed. This helps when an operation fails due to a temporary issue, such as a lack of network connection.

The default behavior retries any error, and starts with a 200ms delay that doubles after each retry up to 6.4 seconds.
This can be customized for all providers on ProviderContainer/ProviderScope by passing a retry parameter:

void main() {
runApp(
ProviderScope(
// You can customize the retry logic, such as to skip
// specific errors or add a limit to the number of retries
// or change the delay
retry: (retryCount, error) {
if (error is SomeSpecificError) return null;
if (retryCount > 5) return null;

return Duration(seconds: retryCount * 2);
},
child: MyApp(),
),
);
}

Alternatively, this can be configured on a per-provider basis by passing a retry parameter to the provider constructor:

Duration retry(int retryCount, Object error) {
if (error is SomeSpecificError) return null;
if (retryCount > 5) return null;

return Duration(seconds: retryCount * 2);
}

(retry: retry)
class TodoList extends _$TodoList {

List<Todo> build() => [];
}

Ref.mounted

The long-awaited Ref.mounted is finally here! It is similar to BuildContext.mounted, but for Ref.

You can use it to check if a provider is still mounted after an async operation:


class TodoList extends _$TodoList {

List<Todo> build() => [];

Future<void> addTodo(String title) async {
// Post the new todo to the server
final newTodo = await api.addTodo(title);
// Check if the provider is still mounted
// after the async operation
if (!ref.mounted) return;

// If it is, update the state
state = [...state, newTodo];
}
}

For this to work, quite a few life-cycle changes were necessary.
Make sure to read the life-cycle changes section.

Generic support (code-generation)

When using code generation, you can now define type parameters for your generated providers. Type parameters work like any other provider parameter, and need to be passed when watching the provider.


T multiply<T extends num>(T a, T b) {
return a * b;
}

// ...

int integer = ref.watch(multiplyProvider<int>(2, 3));
double decimal = ref.watch(multiplyProvider<double>(2.5, 3.5));

Pause/Resume support

In 2.0, Riverpod already had some form of pause/resume support, but it was fairly limited. With 3.0, all ref.listen listeners can be manually paused/resumed on demand:

final subscription = ref.listen(
todoListProvider,
(previous, next) {
// Do something with the new value
},
);

subscription.pause();
subscription.resume();

At the same time, Riverpod now pauses providers in various situations:

  • When a provider is no-longer visible, it is paused (Based off Visibility).
  • When a provider rebuilds, its subscriptions are paused until the rebuild completes.
  • When a provider is paused, all of its subscriptions are paused too.

See the life-cycle changes section for more details.

Unification of the Public APIs

One goal of Riverpod 3.0 is to simplify the API. This includes:

  • Highlighting what ha recommended and what is not
  • Removing needless interface duplicates
  • Making sure all functionalities function in a consistent way

For this sake, a few changes were made:

[StateProvider]/[StateNotifierProvider] and [ChangeNotifierProvider] are discouraged and moved to a different import

Those providers are not removed, but simply moved to a different import. Instead of:

import 'package:riverpod/riverpod.dart';

You should now use:

import 'package:riverpod/legacy.dart';

This is to highlight that those providers are not recommended anymore.
At the same time, those are preserved for backward compatibility.

AutoDispose interfaces are removed

No, the "auto-dispose" feature isn't removed. This only concerns the interfaces. In 2.0, all providers, Refs and Notifiers were duplicated for the sake of auto-dispose ( Ref vs AutoDisposeRef, Notifier vs AutoDisposeNotifier, etc). This was done for the sake of having a compilation error in some edge-cases, but came at the cost of a worse API.

In 3.0, the interfaces are unified, and the previous compilation error is now implemented as a lint rule (using riverpod_lint). What this means concretely is that you can replace all references to AutoDisposeNotifier with Notifier. The behavior of your code should not change.

final provider = NotifierProvider.autoDispose<MyNotifier, int>(
MyNotifier.new,
);

- class MyNotifier extends AutoDisposeNotifier<int> {
+ class MyNotifier extends Notifier<int> {
}

One Ref to rule them all

In Riverpod 2.0, each provider came with its own Ref subclass (FutureProviderRef, StreamProviderRef, etc).
Some Ref had state property, some a future, or a notifier, etc. Although useful, this was a lot of complexity for not much gain. One of the reasons for that is because Notifiers already have the extra properties it had, so the interfaces were redundant.

In 3.0, Ref is unified. No more generic parameter such as Ref<T>, no more FutureProviderRef. We only have one thing: Ref. What this means in practice is, the syntax for generated providers is simplified:

-Example example(ExampleRef ref) {
+Example example(Ref ref) {
return Example();
}
信息

This does not concern WidgetRef, which is intact.
Ref and WidgetRef are two different things.

All updateShouldNotify now use ==

updateShouldNotify is a method that is used to determine if a provider should notify its listeners when a state change occurs. But in 2.0, the implementation of this method varied quite a bit between providers. Some providers used ==, some identical, and some more complex logic.

Starting 3.0, all providers use == to filter notifications.

This can impact you in a few ways:

  • Some of your providers may not notify their listeners anymore in certain situations.
  • Some listeners may be notified more often than before.
  • If you have a large data class that overrides ==, you may see a small performance impact.

If you are impacted by those changes, you can override updateShouldNotify to use a custom implementation:


class TodoList extends _$TodoList {

List<Todo> build() => [];


bool updateShouldNotify(List<Todo> previous, List<Todo> next) {
// Custom implementation
return true;
}
}

Provider life-cycle changes

When reading a provider results in an exception, the error is now wrapped in a ProviderException

Before, if a provider threw an error, Riverpod would sometimes rethrow that error directly:


Future<int> example(Ref ref) async {
throw StateError('Error');
}

// ...
ElevatedButton(
onPressed: () async {
// This will rethrow the StateError
ref.read(exampleProvider).requireValue;

// This also rethrows the StateError
await ref.read(exampleProvider.future);
},
child: Text('Click me'),
);

In 3.0, this is changed. Instead, the error will be encapsulated in a ProviderException that contains both the original error and its stack trace.

信息

AsyncValue.error, ref.listen(..., onError: ...) and ProviderObservers are unaffected by this change, and will still receive the unaltered error.

This has multiple benefits:

  • Debugging is improved, as we have a much better stack trace
  • It is now possible to determine if a provider failed, or if it is in error state because it depends on another provider that failed.

For example, a ProviderObserver can use this to avoid logging the same error twice:

class MyObserver extends ProviderObserver {

void providerDidFail(ProviderObserverContext context, Object error, StackTrace stackTrace) {
if (error is ProviderException) {
// The provider didn't fail directly, but instead depends on a failed provider.
// The error was therefore already logged.
return;
}

// Log the error
print('Provider failed: $error');
}
}

This is used internally by Riverpod in its automatic retry mechanism. The default automatic retry ignores ProviderExceptions:

ProviderContainer(
// Example of the default retry behavior
retry: (retryCount, error) {
if (error is ProviderException) return null;

// ...
},
);

Listeners inside widgets that are not visible are now paused

Now that Riverpod has a way to pause listeners, Riverpod uses that to natively pauses listeners when the widget is not visible. In practice what this means is: Providers that are not used by the visible widget tree are paused.

As a concrete example, consider an application with two routes:

  • A home page, listening to a websocket using a provider
  • A settings page, which does not rely on that websocket

In typical applications, a user first opens the home page and then opens the settings page. This means that while the settings page is open, the homepage is also open, but not visible.

In 2.0, the homepage would actively keep listening to the websocket.
In 3.0, the websocket provider will instead be paused, possibly saving resources.

How it works:
Riverpod relies on Visibility to determine if a widget is visible or not. And when false, all listeners of a Consumer are paused.

It also means that you can rely on Visibility yourself to manually control the pause behavior of your consumers. You can voluntarily set the value to true/false to forcibly resume/pause listeners:

class MyWidget extends StatelessWidget {

Widget build(BuildContext context) {
return Visibility(
visible: false, // This will pause the listeners
child: Consumer(
builder: (context, ref, child) {
// This "watch" will be paused
// until Visibility is set to true
final value = ref.watch(myProvider);
return Text(value.toString());
},
),
);
}
}

If a provider is only used by paused providers, it is paused too

Riverpod 2.0 already had some form of pause/resume support. But it was limited and failed to cover some edge-cases.
Consider:


int example(Ref ref) {
ref.keepAlive();
ref.onCancel(() => print('paused'));
ref.onResume(() => print('resumed'));
return 0;
}

In 2.0, if you were to call ref.read once on this provider, the state of the provider would be maintained, but 'paused' will be printed. This is because calling ref.read does not "listen" to the provider. And since the provider is not "listened" to, it is paused.

This is useful to pause providers that are currently not used! The problem is that in many cases, this optimization does not work.
For example, your provider could be used indirectly through another provider.


int another(Ref ref) {
ref.keepAlive();
return ref.watch(exampleProvider);
}

class MyWidget extends ConsumerWidget {

Widget build(BuildContext context, WidgetRef ref) {
return Button(
onPressed: () {
ref.read(anotherProvider);
},
child: Text('Click me'),
);
}
}

In this scenario, if we click on the button once, then anotherProvider will start listening to our exampleProvider. But anotherProvider is no-longer used and will be paused. Yet exampleProvider will not be paused, because it thinks that it is still being used.
As such, clicking on the button will not print 'paused' anymore.

In 3.0, this is fixed. If a provider is only used by paused providers, it is paused too.

When a provider rebuilds, its previous subscriptions now are kept until the rebuild completes

In 2.0, there was a known inconvenience when using asynchronous providers combined with 'auto-dispose'.

Specifically, when an asynchronous provider watches an auto-dispose provider after an await, the "auto dispose" could be triggered unexpectedly.

Consider:


Stream<int> autoDispose(Ref ref) {
ref.onDispose(() => print('disposed'));
ref.onCancel(() => print('paused'));
ref.onResume(() => print('resumed'));
// A stream that emits a value every second
return Stream.periodic(Duration(seconds: 1), (i) => i);
}


Future<int> asynchronousExample(Ref ref) async {
print('Before async gap');
// An async gap inside a provider ; typically an API call.
// This will dispose the "autoDispose" provider
// before the async operation is completed
await null;

print('after async gap');
// We listen to our auto-dispose provider
// after the async operation
return ref.watch(autoDisposeProvider.future);
}

void main() {
final container = ProviderContainer();
// This will print 'disposed' every second,
// and will constantly print 0
container.listen(asynchronousExampleProvider, (_, value) {
if (value is AsyncData) print('${value.value}\n----');
});
}

In you run this on Dartpad, you will see that its prints:

// First print
Before async gap
after async gap
0
---- // Second and after prints
paused
Before async gap
disposed // The 'autoDispose' provider was disposed during the async gap!
after async gap
0
----
paused
Before async gap
disposed
after async gap
0
----
... // And so on every second

As you can see, this consistently prints 0 every second, because the autoDispose provider repeatedly gets disposed during the async gap. A workaround was to move the ref.watch call before the await statement. But this is error prone, not very intuitive, and not always possible.

In 3.0, this is fixed by delaying the disposal of listeners.
When a provider rebuilds, instead of immediately removing all of its listeners, it pauses them.

The exact same code will now instead print:

// First print
Before async gap
after async gap
0
----
paused
Before async gap
after async gap
resumed
1
----
paused
Before async gap
after async gap
resumed
2
----
... // And so on every second

When a Notifier rebuilds, a new instance of the Notifier class is created

In 2.0, Notifiers instances were created once, then reused for the lifetime of the provider.
This enabled storing custom state in the Notifier class. Unfortunately, this also meant caused some possible race conditions ; especially when we consider the new "offline persistence" feature.

Offline persistence works by differentiating the first build of a provider from the subsequent builds. This is done by checking Ref.isFirstBuild. The problem is, if Notifier instances are reused and a rebuild occurs while Notifier.build is still running, this could confuse offline persistence.

Although unfortunate, for 3.0 the decision was made to break the old behavior and create a new instance of the Notifier class every time the provider rebuilds.

On the upside, it means that you can now safely rely on late final variables initialized during build:


class MyNotifier extends _$MyNotifier {
late final int _cached;


int build() {
// This is now safe, as a new Notifier instance is created upon rebuild
_cached = ref.watch(myProvider);
return 0;
}

void increment() {
print(_cached);
}
}

New testing utilities

ProviderContainer.test

In 2.0, typical testing code would rely on a custom-made utility called createContainer.
In 3.0, this utility is now part of Riverpod, and is called ProviderContainer.test. It creates a new container, and automatically disposes it after the test ends.

void main() {
test('My test', () {
final container = ProviderContainer.test();
// Use the container
// ...
// The container is automatically disposed after the test ends
});
}

You can safely do a global search-and-replace for createContainer to ProviderContainer.test.

NotifierProvider.overrideWithBuild

It is now possible to mock only the Notifier.build method, without mocking the whole notifier. This is useful when you want to initialize your notifier with a specific state, but still want to use the original implementation of the notifier.


class MyNotifier extends _$MyNotifier {

int build() => 0;

void increment() {
state++;
}
}

void main() {
final container = ProviderContainer.test(
overrides: [
myNotifierProvider.overrideWithBuild((ref) {
// Mock the build method to start at 42.
// The "increment" method is unaffected.
return 42;
}),
],
);
}

Future/StreamProvider.overrideWithValue

A while back, FutureProvider.overrideWithValue and StreamProvider.overrideWithValue were removed "temporarily" from Riverpod.
They are finally back!


Future<int> myFutureProvider() async {
return 42;
}

void main() {
final container = ProviderContainer.test(
overrides: [
// Initializes the provider with a value.
// Changing the override will update the value.
myFutureProvider.overrideWithValue(AsyncValue.data(42)),
],
);
}

Statically safe scoping (code-generation only)

Through riverpod_lint, Riverpod now includes a way to detect when scoping is used incorrectly. This lints detects when an override is missing, to avoid runtime errors.

Consider:

// A typical "scoped provider"
(dependencies: [])
Future<int> myFutureProvider() => throw UnimplementedError();

To use this provider, you have two options.
If neither of the following options are used, the provider will throw an error at runtime.

  • Override the provider using ProviderScope before using it:
    class MyWidget extends StatelessWidget {

    Widget build(BuildContext context) {
    return ProviderScope(
    overrides: [
    myFutureProvider.overrideWithValue(AsyncValue.data(42)),
    ],
    // A consumer is necessary to access the overridden provider
    child: Consumer(
    builder: (context, ref, child) {
    // Use the provider
    final value = ref.watch(myFutureProvider);
    return Text(value.toString());
    },
    ),
    );
    }
    }
  • Specify @Dependencies on whatever uses the scoped provider to indicate that it depends on it.
    ([myFuture])
    class MyWidget extends ConsumerWidget {

    Widget build(BuildContext context, WidgetRef ref) {
    // Use the provider
    final value = ref.watch(myFutureProvider);
    return Text(value.toString());
    }
    }
    After specifying @Dependencies, all usages of MyWidget will require the same two options as above:
    • Either override the provider using ProviderScope before using MyWidget
      void main() {
      runApp(
      ProviderScope(
      overrides: [
      myFutureProvider.overrideWithValue(AsyncValue.data(42)),
      ],
      child: MyWidget(),
      ),
      );
      }
    • Or specify @Dependencies on whatever uses MyWidget to indicate that it depends on it.
      ([myFuture])
      class MyApp extends ConsumerWidget {

      Widget build(BuildContext context, WidgetRef ref) {
      // MyApp indirectly uses scoped providers through MyWidget
      return MyWidget();
      }
      }

Other changes changes

AsyncValue

AsyncValue received various changes.

  • It is now "sealed". This enables exhaustive pattern matching:
    AsyncValue<int> value;
    switch (value) {
    case AsyncData():
    print('data');
    case AsyncError():
    print('error');
    case AsyncLoading():
    print('loading');
    // No default case needed
    }
  • valueOrNull has been renamed to value. The old value is removed, as its behavior related to errors was odd. To migrate, do a global search-and-replace of valueOrNull -> value.
  • AsyncValue.isFromCache has been added.
    This flag is set when a value is obtained through offline persistence. It enables your UI to differentiate state coming from the database and state from the server.
  • An optional progress property is available on AsyncLoading. This enables your providers to define the current progress for a request:

    class MyNotifier extends _$MyNotifier {

    Future<User> build() async {
    // You can optionally pass a "progress" to AsyncLoading
    state = AsyncLoading(progress: .0);
    await fetchSomething();
    state = AsyncLoading(progress: 0.5);

    return User();
    }
    }

All Ref listeners now return a way to remove the listener

It is now possible to "unsubscribe" to the various life-cycles listeners:


Future<int> example(Ref ref) {
// onDispose and other life-cycle listeners return a function
// to remove the listener.
final removeListener = ref.onDispose(() => print('dispose));
// Simply call the function to remove the listener:
removeListener();

// ...
}

Weak listeners - listen to a provider without preventing auto-dispose.

When using Ref.listen, you can optionally specify weak: true:


Future<int> example(Ref ref) {
ref.listen(
anotherProvider,
// Specify the flag
weak: true,
(previous, next) {},
);

// ...
}

Specifying this flag will tell Riverpod that it can still dispose the listened provider if it stops being used.

This flag is an advanced feature to help with some niche use-cases regarding combining multiple "sources of truth" in a single provider.