Skip to main content

Riverpod Best Practices You're Probably Missing

Β· 15 min read
Majid HajianDeveloper Advocate

Cover

If you've been building Flutter apps for a while, you've almost certainly encountered Riverpod. It's become one of the most popular state management solutions in the Flutter ecosystem, offering a reactive caching and data-binding framework that addresses many of the limitations found in other approaches.

That popularity is also exactly why easy mistakes can show up! The classic "my widget isn't rebuilding" bug, stale notifier references after async gaps, unnecessary subscriptions that waste resources, or memory leaks from undisposed instances, these are all common issues that can silently degrade your app's performance and reliability.

In this article, we'll connect the dots between the Riverpod implementation (straight from the Riverpod repository) and the Riverpod lint rules that prevent those problems, by showing what's actually happening under the hood.

info

You can find the full source code examples for all the rules discussed in this article in our examples repository.

Let's get started!

The Foundation: Understanding Ref and the Lifecycle​

Every interaction with Riverpod providers happens through the Ref object. This is the core building block that allows providers to interact with other providers and manage their lifecycle.

The critical thing to understand is that Ref is not a permanent handle. It's tied to a specific provider lifecycle, and Riverpod validates that lifecycle on every operation. When you call methods like read, watch, or listen, the Ref first checks whether it's still valid. If the provider has been disposed or rebuilt in the meantime, the call fails with an exception:

From riverpod/lib/src/core/ref.dart

class UnmountedRefException implements Exception {
UnmountedRefException(this.origin);

final ProviderBase<Object?> origin;

// ....
}

This exception is the foundation of several lint rules we'll explore. The key insight is that Ref has a lifecycle, and using it after that lifecycle ends leads to runtime exceptions rather than silent bugs.

The mounted Property​

From riverpod/lib/src/core/ref.dart
bool get mounted => !_element._disposed && identical(_element.ref, this);

The mounted property is your safeguard against using a disposed Ref. When a provider rebuilds or gets disposed, the old Ref becomes invalid. Any attempt to use it will throw UnmountedRefException.

The read vs watch Distinction​

One of the most important design decisions in Riverpod is the separation between "reading once" and "subscribing to changes." Let's examine how WidgetRef documents this in the source code:

From flutter_riverpod/lib/src/core/widget_ref.dart
StateT watch<StateT>(ProviderListenable<StateT> provider);

The difference is fundamental:

  • watch: Subscribes to changes. When the provider's value changes, the widget rebuilds.
  • read: Gets the value once. No subscription. The widget won't rebuild when the value changes.

The ref.read Inside build Bug​

This is what avoid-ref-read-inside-build catches.

❌ Bad: Widget won't update when counter changes!
class HomeConsumerWidget extends ConsumerWidget {
const HomeConsumerWidget({super.key});


Widget build(BuildContext context, WidgetRef ref) {
// πŸ’₯ Using read inside build β€” no subscription!
final counter = ref.read(counterProvider);

return Text('$counter');
}
}

The problem: read doesn't subscribe. When counterProvider emits a new value, this widget won't rebuild. The display shows stale data.

βœ… Good: Widget updates when counter changes
class HomeConsumerWidget extends ConsumerWidget {
const HomeConsumerWidget({super.key});


Widget build(BuildContext context, WidgetRef ref) {
// βœ… Using watch inside build β€” subscribed to changes!
final counter = ref.watch(counterProvider);

return Text('$counter');
}
}

As the Riverpod documentation states: "Riverpod enhances the compiler by having common mistakes be a compilation-error." This lint rule catches what the compiler cannot.

The ref.watch Outside build Bug​

The flip side is equally problematic. This is what avoid-ref-watch-outside-build catches.

Looking at the source code for the watch implementation in the consumer:

From flutter_riverpod/lib/src/core/consumer.dart

StateT watch<StateT>(ProviderListenable<StateT> target) {
_assertNotDisposed();
return _dependencies
.putIfAbsent(target, () {
final oldDependency = _oldDependencies?.remove(target);

if (oldDependency != null) {
return oldDependency;
}

final sub = container.listen<StateT>(
target,
(_, _) => markNeedsBuild(), // πŸ‘ˆ Triggers rebuild
);
_applyTickerMode(sub);
return sub;
})
.readSafe()
.valueOrProviderException
as StateT;
}

Notice the markNeedsBuild() call, this is what makes watch trigger rebuilds. When used outside of build, this subscription is wasteful and potentially buggy:

❌ Bad: Subscribing where it doesn't make sense
class HomeConsumerWidget extends ConsumerWidget {
const HomeConsumerWidget({super.key});

void callback(WidgetRef ref) {
// πŸ’₯ Using watch outside build β€” wasteful subscription!
final value = ref.watch(counterProvider);
}


Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
return Text('$counter');
}
}

When you use watch in a callback, you're creating a subscription that won't trigger any meaningful rebuild (the callback already finished). Use read instead:

βœ… Good: Read for event handlers
class HomeConsumerWidget extends ConsumerWidget {
const HomeConsumerWidget();

void callback(WidgetRef ref) {
// βœ… Using read in callback β€” no unnecessary subscription
final value = ref.read(counterProvider);
}


Widget build(BuildContext context, WidgetRef ref) {
final counter = ref.watch(counterProvider);
return Text('$counter');
}
}

The rule of thumb:

  • Inside build: Use watch to subscribe to changes
  • Outside build (callbacks, initState, etc.): Use read to get the value once

The Async Gap Problem​

One of the most dangerous patterns in Riverpod is using ref or state after an async gap. Let's look at why by examining the Ref source:

From riverpod/lib/src/core/ref.dart
void _throwIfInvalidUsage() {
assert(
_debugCallbackStack == 0,
'Cannot use Ref or modify other providers inside life-cycles/selectors.',
);
if (!mounted) {
throw UnmountedRefException(_element.origin);
}
}

Every operation on Ref checks if it's still mounted. During an async operation, the provider might rebuild or dispose, making the old Ref invalid.

This is what use-ref-and-state-synchronously catches.

❌ Bad: Using state after async gap without checking mounted

class CountNotifier extends _$CountNotifier {

int build() => 0;

Future<void> incrementDelayed() async {
await Future<void>.delayed(const Duration(seconds: 1));
// πŸ’₯ The notifier might be disposed during the delay!
state += 1;
}
}

The fix is to check ref.mounted after async operations:

βœ… Good: Check mounted before using state

class CountNotifier extends _$CountNotifier {

int build() => 0;

Future<void> incrementDelayed() async {
await Future<void>.delayed(const Duration(seconds: 1));
if (ref.mounted) {
state += 1; // βœ… Safe to use after checking mounted
}
}
}

The same principle applies to ref.read after async gaps. This is what use-ref-read-synchronously catches:

❌ Bad: ref.read after async gap
class HomeConsumerState extends ConsumerState<HomeConsumerStatefulWidget> {

Widget build(BuildContext context) {
return FooWidget(
onChange: (value) async {
ref.read<WithAsync>();
await fetch();
// πŸ’₯ Widget might be unmounted!
ref.read<WithAsync>();
},
);
}
}
βœ… Good: Check mounted before ref.read
class HomeConsumerState extends ConsumerState<HomeConsumerStatefulWidget> {

Widget build(BuildContext context) {
return FooWidget(
onChange: (value) async {
ref.read<WithAsync>();
await fetch();
if (mounted) {
ref.read<WithAsync>(); // βœ… Safe after mounted check
}
},
);
}
}

The Notifier build Method Is Sacred​

Let's look at how Riverpod handles notifier initialization. The notifier_provider.dart source makes this very clear:

From riverpod/lib/src/core/provider/notifier_provider.dart
/// The error message for when a notifier is used when uninitialized.

const uninitializedElementError = '''
Tried to use a notifier in an uninitialized state.
This means that you tried to either:
- Use ref/state inside the constructor of a notifier.
In this case you should move your logic inside the "build" method instead.
- Use ref/state after the notifier was disposed.
In this case, consider using `ref.onDispose` earlier in your notifier's lifecycle
to abort any pending logic that could try to use `ref/state`.
''';

This error message directly explains why avoid-notifier-constructors exists:

❌ Bad: Logic in constructor
class Counter extends Notifier<int> {
var state = 0;

// πŸ’₯ Constructor logic runs before Riverpod initializes the notifier!
Counter() {
state = 1; // This can cause initialization issues
}


int build() {
return state;
}

void increment() {
state++;
}
}
βœ… Good: Logic in build method
class Counter extends Notifier<int> {

int build() {
return 0; // βœ… Initialized in the build method
}

void increment() {
state++;
}
}

Why Public Properties on Notifiers Are Problematic​

The AnyNotifier base class shows how notifiers are designed to work with a single state property:

From riverpod/lib/src/core/provider/notifier_provider.dart
/// The value currently exposed by this notifier.
///
/// Invoking the setter will notify listeners if [updateShouldNotify] returns true.


StateT get state {
final ref = $ref;
ref._throwIfInvalidUsage();

return ref._element.readSelf().valueOrRawException;
}



set state(StateT newState) {
final ref = $ref;
ref._throwIfInvalidUsage();

ref._element.setValueFromState(newState);
}

Notice how state triggers _throwIfInvalidUsage() and properly notifies listeners. Public properties bypass this mechanism entirely.

This is what avoid-public-notifier-properties catches:

❌ Bad: Public property bypasses state management
class MyNotifier extends Notifier<int> {
int get _privateGetter => 0;

// πŸ’₯ This property doesn't go through Riverpod's state management!
int get publicGetter => _privateGetter;


int build() => 0;
}
βœ… Good: Everything goes through state
class MyState {
final int left;
final int right;

MyState(this.left, this.right);
}

class MyNotifier extends Notifier<MyState> {

MyState build() => MyState(0, 1);
}

The Stale Notifier Reference Trap​

Assigning notifiers to variables is a common mistake that leads to stale references. Let's understand why by looking at how notifier access works:

From riverpod/lib/src/core/provider/notifier_provider.dart
extension ClassBaseX<StateT, ValueT> on AnyNotifier<StateT, ValueT> {
$ClassProviderElement<AnyNotifier<StateT, ValueT>, StateT, Object?, Object?>
requireElement() {
final element = elementOrNull();
if (element == null) {
throw StateError(uninitializedElementError);
}

ref._throwIfInvalidUsage(); // πŸ‘ˆ Checks if ref is still valid

return element;
}
}

When you assign a notifier to a variable and use it after an async gap, you might be holding a reference to an already-unmounted notifier.

This is what avoid-assigning-notifiers catches:

❌ Bad: Stale notifier reference
class HomeConsumerState extends ConsumerState<HomeConsumerStatefulWidget> {

Widget build(context) {
return FooWidget(
onChange: (value) async {
// πŸ’₯ This reference can become stale!
final notifier = ref.read<MyNotifier>(counterProvider.notifier);

await someAsyncOperation();

notifier.fn(); // πŸ’₯ Notifier might be unmounted!
},
);
}
}
βœ… Good: Always get fresh reference
class HomeConsumerState extends ConsumerState<HomeConsumerStatefulWidget> {

Widget build(context) {
return FooWidget(
onChange: (value) async {
ref.read<MyNotifier>(counterProvider.notifier).someFn();

await someAsyncOperation();

// βœ… Get fresh reference after async gap
ref.read<MyNotifier>(counterProvider.notifier).fn();
},
);
}
}

The dispose and Ref Don't Mix​

Looking at the Ref lifecycle, we can see why using ref inside dispose is dangerous:

From riverpod/lib/src/core/ref.dart
RemoveListener onDispose(void Function() listener) {
_throwIfInvalidUsage();

final list = _onDisposeListeners ??= [];
list.add(listener);

return () => list.remove(listener);
}

The key insight from the UnmountedRefException message is clear: "You tried to use Ref inside onDispose or other life-cycles. This is not supported, as the provider is already being disposed."

This is what avoid-ref-inside-state-dispose catches:

❌ Bad: Using ref in dispose
class _SomeState extends ConsumerState<SomeWidget> {

void dispose() {
// πŸ’₯ Providers might already be disposed!
ref.read(provider).doSomething();

super.dispose();
}
}
βœ… Good: Don't use ref in dispose
class _SomeState extends ConsumerState<SomeWidget> {

void dispose() {
// βœ… Clean up without using ref
super.dispose();
}
}

The onDispose Callback Gap​

Riverpod's Ref provides extensive documentation about proper disposal patterns:

From riverpod/lib/src/core/ref.dart
RemoveListener onDispose(void Function() listener) {
_throwIfInvalidUsage();

final list = _onDisposeListeners ??= [];
list.add(listener);

return () => list.remove(listener);
}

This is what dispose-provided-instances catches:

❌ Bad: dispose() is never called
Provider.autoDispose((ref) {
// πŸ’₯ Memory leak! dispose() is never called
final instance = DisposableService();

return instance;
});

class DisposableService {
void dispose() {}
}
βœ… Good: dispose() is called via onDispose
Provider.autoDispose((ref) {
final instance = DisposableService();

ref.onDispose(instance.dispose); // βœ… Cleanup happens

return instance;
});

class DisposableService {
void dispose() {}
}

The AsyncValue Pattern Matching Trap​

Riverpod's AsyncValue is designed for safe asynchronous state handling. Let's look at how it tracks values:

From riverpod/lib/src/core/async_value.dart

ValueT? get value => _value?.$1;

bool get hasValue => _value != null;

The key insight: value can be null either because it's still loading OR because the actual value is null. This is particularly dangerous with nullable types.

This is what avoid-nullable-async-value-pattern catches:

❌ Bad: Unsafe pattern on nullable AsyncValue
void fn() {
switch (asyncValue) {
// πŸ’₯ If the actual value is null, this won't match even when data is loaded!
case AsyncValue<int?>(:final value?):
print(value);
}
}
βœ… Good: Use hasValue for nullable types
void fn() {
switch (asyncValue) {
// βœ… Explicitly check hasValue for nullable types
case AsyncValue<int?>(:final value, hasValue: true):
print(value);
}
}

Provider Arguments Need Stable Equality​

When using family providers with arguments, Riverpod relies on equality checks to determine if a "new" argument is logically the same as the old one. The official documentation warns against dynamically creating providers, and the same principle applies to arguments.

This is what prefer-immutable-provider-arguments catches:

❌ Bad: Arguments without consistent equality
// πŸ’₯ New instance every time β€” Riverpod can't cache properly!
ref.watch(someProvider(SomeClassWithoutEquals()));

// πŸ’₯ Lists don't have value equality by default!
ref.watch(someProvider([42]));

// πŸ’₯ Functions are never equal!
ref.watch(someProvider(() { ... }));
βœ… Good: Arguments with stable equality
ref.watch(someProvider(SomeClassWithEquals()));
ref.watch(someProvider(const SomeClass()));
ref.watch(someProvider(const Object()));
ref.watch(someProvider(const [42])); // βœ… const lists have value equality
ref.watch(someProvider(const {'string': 42}));

If you pass a value that doesn't support stable equality checks, you can run into:

  • Unnecessary rebuilds: Your widget rebuilds even if nothing meaningful changed
  • Lost caching: Riverpod thinks you're asking for "new" data every time
  • Stale data: Providers don't reuse results across identical inputs

Calling Notifier Methods Inside build​

The build method can be invoked frequently, and calling notifier methods inside it can lead to unwanted side effects.

This is what avoid-calling-notifier-members-inside-build catches:

❌ Bad: Side effect in build
class HomeConsumerState extends ConsumerState<HomeConsumerStatefulWidget> {

Widget build(BuildContext context) {
final counter = ref.watch<Counter>(provider.notifier);

counter.increment(); // πŸ’₯ Called on every rebuild!
return Container(); // Placeholder to keep the example syntactically valid
}
}
βœ… Good: Side effects in callbacks
class HomeConsumerState extends ConsumerState<HomeConsumerStatefulWidget> {

Widget build(BuildContext context) {
final counter = ref.watch<Counter>(provider.notifier);

return Button(onTap: counter.increment); // βœ… Called only on user interaction
}
}

Code Style: Unnecessary Consumer Widgets​

Sometimes developers extend ConsumerWidget but never actually use the ref parameter. This adds unnecessary overhead.

This is what avoid-unnecessary-consumer-widgets catches:

❌ Bad: ConsumerWidget without using ref
// πŸ’₯ This widget doesn't use ref at all!
class HomeConsumerWidget extends ConsumerWidget {
const HomeConsumerWidget({super.key});


Widget build(BuildContext context, WidgetRef ref) {
return Text('Hello'); // ref is never used
}
}
βœ… Good: Regular StatelessWidget
class HomeWidget extends StatelessWidget {
const HomeWidget({super.key});


Widget build(BuildContext context) {
return Text('Hello');
}
}

Or if you need ref only in a specific part of the widget tree, use Consumer:

βœ… Good: Using Consumer for scoped ref access
class HomeWidget extends StatelessWidget {
const HomeWidget({super.key});


Widget build(BuildContext context) {
return Consumer(
builder: (context, ref, child) {
final value = ref.watch(helloWorldProvider);
return Text(value);
},
);
}
}

Enable All Riverpod Rules​

Ready to add these rules to your project? DCM includes rules for specific packages like Riverpod, and enabling them is a great way to catch bugs early.

Add the following to your analysis_options.yaml:

analysis_options.yaml
dcm:
rules:
# Prevent missing UI updates
- avoid-ref-read-inside-build
# Prevent wasteful subscriptions
- avoid-ref-watch-outside-build
# Prevent UnmountedRefException crashes
- use-ref-and-state-synchronously
- use-ref-read-synchronously
# Prevent initialization issues
- avoid-notifier-constructors
# Prevent bypassed state management
- avoid-public-notifier-properties
# Prevent stale references
- avoid-assigning-notifiers
# Prevent memory leaks
- dispose-provided-instances
# Prevent incorrect pattern matching
- avoid-nullable-async-value-pattern
# Prevent side effects in build
- avoid-calling-notifier-members-inside-build
# Prevent ref usage in dispose
- avoid-ref-inside-state-dispose
# Prevent unnecessary Consumer overhead
- avoid-unnecessary-consumer-widgets
# Prevent caching issues with provider arguments
- prefer-immutable-provider-arguments

To discover all available Riverpod rules and filter by other packages you use, visit the rules page and use the "Categories" filter.

Summary​

Understanding the Riverpod source code reveals why these lint rules exist. They're not arbitrary style preferences but safeguards against real bugs, and they help you write more robust Riverpod code and catch bugs before they reach production.

Happy coding!

Enjoying this article?

Subscribe to get our latest articles and product updates by email.