-
-
Notifications
You must be signed in to change notification settings - Fork 956
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add network debounce/cancellation guide
- Loading branch information
1 parent
441977e
commit ef00867
Showing
23 changed files
with
871 additions
and
1 deletion.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,11 @@ | ||
macos | ||
linux | ||
web | ||
windows | ||
android | ||
ios | ||
test | ||
|
||
# Dependencies | ||
/node_modules | ||
|
||
|
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,141 @@ | ||
--- | ||
title: Debouncing/Cancelling network requests | ||
--- | ||
|
||
import { Link } from "../../src/components/Link"; | ||
import { AutoSnippet, When } from "../../src/components/CodeSnippet"; | ||
import homeScreen from "!raw-loader!./cancel/home_screen.dart"; | ||
import extension from "!raw-loader!./cancel/extension.dart"; | ||
import detailScreen from "./cancel/detail_screen"; | ||
import detailScreenCancel from "./cancel/detail_screen_cancel"; | ||
import detailScreenDebounce from "./cancel/detail_screen_debounce"; | ||
import providerWithExtension from "./cancel/provider_with_extension"; | ||
|
||
As applications grow in complexity, it's common to have multiple network requests | ||
in flight at the same time. For example, a user might be typing in a search box | ||
and triggering a new request for each keystroke. If the user types quickly, the | ||
application might have many requests in flight at the same time. | ||
|
||
Alternatively, a user might trigger a request, then navigate to a different page | ||
before the request completes. In this case, the application might have a request | ||
in flight that is no longer needed. | ||
|
||
To optimize performance in those situations, there are a few techniques you can | ||
use: | ||
|
||
- "Debouncing" requests. This means that you wait until the user has stopped | ||
typing for a certain amount of time before sending the request. This ensures | ||
that you only send one request for a given input, even if the user types | ||
quickly. | ||
- "Cancelling" requests. This means that you cancel a request if the user | ||
navigates away from the page before the request completes. This ensures that | ||
you don't waste time processing a response that the user will never see. | ||
|
||
In Riverpod, both of these techniques are can be implemented in a similar way. | ||
The key is to use `ref.onDispose` combined with "automatic disposal" or `ref.watch` | ||
to achieve the desired behavior. | ||
|
||
To showcase this, we will make a simple application with two pages: | ||
|
||
- A home screen, with a button which opens a new page | ||
- A detail page, which displays a random activity from the [Bored API](https://www.boredapi.com/), | ||
with the ability to refresh the activity. | ||
See <Link documentID="case_studies/pull_to_refresh" /> for information | ||
on how to implement pull-to-refresh. | ||
|
||
We will then implement the following behaviors: | ||
|
||
- If the user opens the detail page and then navigates back immediately, | ||
we will cancel the request for the activity. | ||
- If the user refreshes the activity multiple times in a row, we will debounce | ||
the requests so that we only send one request after the user stops refreshing. | ||
|
||
## The application | ||
|
||
<img | ||
src="/img/case_studies/cancel/app.gif" | ||
alt="Gif showcasing the application, opening the detail page and refreshing the activity." | ||
/> | ||
|
||
First, let's create the application, without any debouncing or cancelling. | ||
We won't use anything fancy here, and stick to a plain `FloatingActionButton` with | ||
a `Navigator.push` to open the detail page. | ||
|
||
First, let's start with defining our home screen. As usual, | ||
let's not forget to specify a `ProviderScope` at the root of our application. | ||
|
||
<AutoSnippet title="lib/src/main.dart" raw={homeScreen} /> | ||
|
||
Then, let's define our detail page. | ||
To fetch the activity and implement pull-to-refresh, refer | ||
to the <Link documentID="case_studies/pull_to_refresh" /> case study. | ||
|
||
<AutoSnippet title="lib/src/detail_screen.dart" {...detailScreen} /> | ||
|
||
## Cancelling requests | ||
|
||
Now that we have a working application, let's implement the cancellation logic. | ||
|
||
To do so, we will use `ref.onDispose` to cancel the request when the user | ||
navigates away from the page. For this to work, it is important that the | ||
automatic disposal of providers is enabled. | ||
|
||
The exact code needed to cancel the request will depend on the HTTP client. | ||
In this example, we will use `package:http`, but the same principle applies | ||
to other clients. | ||
|
||
The key here that `ref.onDispose` will be called when the user navigates away. | ||
That is because our provider is no-longer used, and therefore disposed | ||
thanks to automatic disposal. | ||
We can therefore use this callback to cancel the request. When using `package:http`, | ||
this can be done by closing our HTTP client. | ||
|
||
<AutoSnippet {...detailScreenCancel} /> | ||
|
||
## Debouncing requests | ||
|
||
Now that we have implemented cancellation, let's implement debouncing. | ||
At the moment, if the user refreshes the activity multiple times in a row, | ||
we will send a request for each refresh. | ||
|
||
Technically speaking, now that we have implemented cancellation, this is not | ||
a problem. If the user refreshes the activity multiple times in a row, | ||
the previous request will be cancelled, when a new request is made. | ||
|
||
However, this is not ideal. We are still sending multiple requests, and | ||
wasting bandwidth and server resources. | ||
What we could instead do is delay our requests until the user stops refreshing | ||
the activity for a fixed amount of time. | ||
|
||
The logic here is very similar to the cancellation logic. We will again | ||
use `ref.onDispose`. However, the idea here is that instead of | ||
closing an HTTP client, we will rely on `onDispose` to abort the request | ||
before it starts. | ||
We will then arbitrarily wait for 500ms before sending the request. | ||
Then, if the user refreshes the activity again before the 500ms have elapsed, | ||
`onDispose` will be invoked, aborting the request. | ||
|
||
:::info | ||
To abort requests, a common practice is to voluntarily throw. | ||
It is safe to throw inside providers after the provider has been disposed. | ||
The exception will naturally be caught by Riverpod and be ignored. | ||
::: | ||
|
||
<AutoSnippet {...detailScreenDebounce} /> | ||
|
||
## Going further: Doing both at once | ||
|
||
We now know how to debounce and cancel requests. | ||
But currently, if we want to do another request, we need to copy-paste | ||
the same logic in multiple places. This is not ideal. | ||
|
||
However, we can go further and implement a reusable utility to do both at once. | ||
|
||
The idea here is to implement an extension method on `Ref` that will | ||
handle both cancellation and debouncing in a single method. | ||
|
||
<AutoSnippet raw={extension} /> | ||
|
||
We can then use this extension method in our providers as followed: | ||
|
||
<AutoSnippet {...providerWithExtension} /> |
61 changes: 61 additions & 0 deletions
61
website/docs/case_studies/cancel/detail_screen/codegen.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,61 @@ | ||
import 'dart:convert'; | ||
|
||
import 'package:flutter/material.dart'; | ||
import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||
import 'package:freezed_annotation/freezed_annotation.dart'; | ||
import 'package:http/http.dart' as http; | ||
import 'package:riverpod_annotation/riverpod_annotation.dart'; | ||
|
||
part 'codegen.freezed.dart'; | ||
part 'codegen.g.dart'; | ||
|
||
/* SNIPPET START */ | ||
@freezed | ||
class Activity with _$Activity { | ||
factory Activity({ | ||
required String activity, | ||
required String type, | ||
required int participants, | ||
required double price, | ||
}) = _Activity; | ||
|
||
factory Activity.fromJson(Map<String, dynamic> json) => | ||
_$ActivityFromJson(json); | ||
} | ||
|
||
@riverpod | ||
Future<Activity> activity(ActivityRef ref) async { | ||
final response = await http.get( | ||
Uri.https('www.boredapi.com', '/api/activity'), | ||
); | ||
|
||
final json = jsonDecode(response.body) as Map; | ||
return Activity.fromJson(Map.from(json)); | ||
} | ||
|
||
class DetailPageView extends ConsumerWidget { | ||
const DetailPageView({super.key}); | ||
|
||
@override | ||
Widget build(BuildContext context, WidgetRef ref) { | ||
final activity = ref.watch(activityProvider); | ||
|
||
return Scaffold( | ||
appBar: AppBar( | ||
title: const Text('Detail page'), | ||
), | ||
body: RefreshIndicator( | ||
onRefresh: () => ref.refresh(activityProvider.future), | ||
child: ListView( | ||
children: [ | ||
switch (activity) { | ||
AsyncValue(:final valueOrNull?) => Text(valueOrNull.activity), | ||
AsyncValue(:final error?) => Text('Error: $error'), | ||
_ => const Center(child: CircularProgressIndicator()), | ||
}, | ||
], | ||
), | ||
), | ||
); | ||
} | ||
} |
Oops, something went wrong.