Michael Debertol

Debouncing a FutureProvider

FlutterRiverpod

Note: This is mostly copied from an official example.

When to Debounce

Debouncing can be useful whenever a provider relies on user input that can change quickly, like a search query.

How to Debounce

Let's look right at the code:

/// This exception indicates that a request has been aborted.
class AbortedException implements Exception {}

final itemProvider = FutureProvider<Item>(
(ref) async {
// Get the search query that the user entered
final query = ref.watch(queryProvider);

// If this provider is destroyed (i.e. a new provider for a different
// request is created), we should abort this request.
var shouldAbort = false;
ref.onDispose(() {
shouldAbort = true;
});
await Future.delayed(_debounceDuration);
if (shouldAbort) {
// TODO: abort
}

// Fetch the items
return fetchItems(query);
},
);

ref.onDispose allows us to register a callback that is triggered whenever a provider is about to be destroyed. The provider will be destroyed whenever a new provider is created for a different query. If, after a delay, we notice that the provider has been destroyed, we can abort the request.

Aborting a Request by Throwing an Exception

One way to abort a request is by throwing an exception:

/// This exception indicates that a request has been aborted.
class AbortedException implements Exception {}

final itemProvider = FutureProvider<List<Item>>(
(ref) async {
// Get the search query that the user entered
final query = ref.watch(queryProvider);

// If this provider is destroyed (i.e. a new provider for a different
// request is created), we should abort this request.
var shouldAbort = false;
ref.onDispose(() {
shouldAbort = true;
});
await Future.delayed(_debounceDuration);
if (shouldAbort) {
throw AbortedException();
}

// Fetch the items
return fetchItems(query);
},
);

In this example we're creating a custom AbortedException class that makes our code a bit more readable and makes it clear that we're throwing an Exception to abort the request, not because of an actual error.

Since the provider was disposed its result will be ignored and throwing an Exception will not affect the rest of the app. It will however show up when you debug the app and you have "break on exceptions" enabled, which can be a bit annoying.

Creating a Convenience Method

This approach is very general and we can extract the logic from above into a convenience method that we can reuse:

class AbortedException implements Exception {}

Future<void> providerDebounce(Duration debounceDuration, Ref ref) async {
var shouldAbort = false;
ref.onDispose(() {
shouldAbort = true;
});
await Future.delayed(debounceDuration);
if (shouldAbort) {
throw AbortedException();
}
}

Debouncing is now as straightforward as calling await providerDebounce(_debounceDuration, ref); in the provider.

Our itemProvider would now look like this:

final itemProvider = FutureProvider<Item>(
(ref) async {
// Get the search query that the user entered
final query = ref.watch(queryProvider);

await providerDebounce(_debounceDuration, ref);

// Fetch the items
return fetchItems(query);
},
);

Aborting Without Exceptions

Instead of throwing an Exception to abort we can also return a dummy value:

final itemProvider = FutureProvider<List<Item>>(
(ref) async {
// Get the search query that the user entered
final query = ref.watch(queryProvider);

// If this provider is destroyed (i.e. a new provider for a different
// request is created), we should abort this request.
var shouldAbort = false;
ref.onDispose(() {
shouldAbort = true;
});
await Future.delayed(_debounceDuration);
if (shouldAbort) {
return [];
}

// Fetch the items
return fetchItems(query);
},
);

Here we're returning an empty list. This return value will be ignored, but most importantly, the request further down will not be initiated.

Another possibility is to return a Future that never completes. This can be achieved using Completer<List<Item>>().future.

Creating a Convenience Method

For this approach a convenience method will have to look like this:

Future<bool> providerDebounce(Duration debounceDuration, Ref ref) async {
var shouldAbort = false;
ref.onDispose(() {
shouldAbort = true;
});
await Future.delayed(debounceDuration);
return shouldAbort;
}

Usage would then look like this:

final itemProvider = FutureProvider<List<Item>>(
(ref) async {
// Get the search query that the user entered
final query = ref.watch(queryProvider);

if (await providerDebounce(_debounceDuration, ref)) {
// return a dummy value
return [];
}

// Fetch the items
return fetchItems(query);
},
);