Skip to content

Commit

Permalink
Add network debounce/cancellation guide
Browse files Browse the repository at this point in the history
  • Loading branch information
rrousselGit committed Oct 30, 2023
1 parent 441977e commit ef00867
Show file tree
Hide file tree
Showing 23 changed files with 871 additions and 1 deletion.
8 changes: 8 additions & 0 deletions website/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
macos
linux
web
windows
android
ios
test

# Dependencies
/node_modules

Expand Down
141 changes: 141 additions & 0 deletions website/docs/case_studies/cancel.mdx
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 website/docs/case_studies/cancel/detail_screen/codegen.dart
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()),
},
],
),
),
);
}
}
Loading

0 comments on commit ef00867

Please sign in to comment.