A common use-case in modern applications is a "retry" feature – either refresh a feed or some request failed.
Thanks to providers being declarative, it is possible to have built-in support for such feature.
First, this would require #39 to make the syntax reasonable.
This would consist of the following things:
A MyProvider.retry((ref) {...}) modifier, to mark a state as retryable.
This would wrap the exposed object in a Retry<T>.
For example, if we have:
final provider = FutureProvider((ref) async => 42);
Widget build(context) {
AsyncValue<int> value = useProvider(provider);
}
Then using the .retry modifier, we would have:
final provider = FutureProvider.retry((ref) async => 42);
Widget build(context) {
Retry<AsyncValue<int>> value = useProvider(provider);
}
Where Retry is:
abstract class Retry<T> {
T get value.
Future<T> retry();
}
The .retry modifier would be specific to Future & StreamProvider only
The UI can then freely call Retry.retry(), which would destroy the previous state and re-create a new state for the given provider.
The result of Retry.retry is a Future<T> that resolves when the state is re-created
The constructor of a .retry provider would expose an parameter to define what state should be displayed during the AsyncValue.data to AsyncValue.data transition:
AsyncValue.loadingThis parameter could be named gaplessPlayback, which is the named used by the Image widget for the same behavior.
We could also have an enum.
A provider marked with .rety can hook-up on the retry event to perform some clean-up using ref:
final provider = FutureProvider.retry((ref) async {
ref.onRetry(() {
// TODO: clear cache, so that the next request doesn't simply return the same result.
});
return 42;
});
When using ref.read(futureProvider), the value obtained is always a Stream<T>, even for FutureProvider.retry – as the value exposed by a retryable FutureProvider can change over time.
All in all a typical usage would be a FutureProvider that performs an HTTP request.
Then, the UI would expose a way for users to restart the request, such as with a pull-to-refresh or a "retry" button.
In code, this would look like this:
class Todos {
static final provider = FutureProvider.retry(
(ref) async {
final repository = ref.read(Repository.provider);
return repository.fetchTodos();
},
// during retry, keep showing the previous state
gaplessPlayback: true,
);
}
...
Widget build(context) {
final todos = useProvider(User.provider);
return todos.value.when(
loading: () => const CircularProgressIndicator(),
error: (err, stack) {
return Column(
children: [
Text('Error $err'),
RaisedButton(
onPressed: () => user.retry(),
child: Text('retry'),
),
],
);
},
data: (todos) {
return RefreshIndicator(
onRefresh: () => user.retry(),
child: ListView(
children: [
for (final todo in todos) TodoItem(todo: todo),
],
),
);
},
);
}
This would be fantastic.
It's essentially what I did with notifier.reload() here: https://github.com/flutterdata/data_state
(That notifier is a StateNotifier.)
Question: Since (a) both FutureProvider.retry and StreamProvider.retry would return a Stream<T> and (b) StateNotifier is also a value changing over time, is there any reason why StateNotifier wouldn't have a retry?
Also: using AsyncValue, what is the idiomatic way to display a loading state and a data state at the same time? I like the idea of gaplessPlayback but even this improvement implies a "loading or data" situation.
I'm working on an app where the "pull to refresh" feature keeps before-transition data and shows a LinearProgressIndicator at the top.
It would be great to see AsyncValue (elegantly) support these scenarios. First to get rid of data_state as Flutter Data's async-handling-utility, and most importantly to push a standard in the Flutter community.
StateProvider could have a retry, but StateNotifierProvider is more difficult
Because a retry would create a new instance of the exposed StateNotifier.
So we'd need "ref.read(retryStateNotifierProvider)" to return a StateNotifier
We can think about adding a retry to StateNotifierProvider in the future, although I would expect future/stream + their family to cover most needs
Yes I meant StateNotifierProvider. I'm not sure about the practical difficulty, but conceptually Stream and StateNotifier are basically the same thing, aren't they?
I am currently using a ~retry with a StateNotifier (through data_state) which I'd like to replace.
While waiting for this, how can we do it in the current state of the library?
@edisonywh i think you have to write a custom StateNotifier.
Either a StateNotifier/ChangeNotifier or a Stream, yes.
Kind of like how you'd do it in every other architectures by doing it yourself.
This issue is more about solving the problem once and for all.
This will be delayed by #49 – which would introduce a ComputedFuture/ComputedStateNotifier/...
With #49, a retry feature could be implemented as:
final myFutureRetryToken = StateProvider((_) => 0);
final myFuture = ComputedFuture<String>((ref, watch) async {
watch(myFutureRetryToken); // force the future to be recomputed when retryToken is modified
await Future.delayed(...);
return 'Hello world';
}
where to retry muFuture, we would do myFutureRetryToken.read(context).state++
This issue can still be useful to make the syntax less verbose & more readable. But it's less important.
Thinking about it, now that everything is "computed", we could have:
final provider = StateNotifierProvider((_) => Counter());
Counter newCounter = context.retry(provider);
When retry becomes recreate 😛
Well that's the same thing 😜
I quite like this context.retry(provider), I think I'll go with that.
It does not introduce new fancy concepts like a Retry<T> object.
It's easy to implement.
And it also works on _anything_, not just FutureProvider/StreamProvider
We'd still need the gaplessPlayback variable on StreamProvider/FutureProvider, but that's not much of an issue.
will this be part of the dev release?
Yes. It already is developed. The retry feature was implicitly developed for other things. I just need to make a public API and some tests for it.
This is available in 0.6.0-dev:
final productsProvider = FutureProvider((ref) async {
final response = await httpClient.get('https://host.com/products');
return Products.fromJson(response.data);
});
class Example extends HookWidget {
@override
Widget build(BuildContext context) {
final Products products = useProvider(productsProvider);
return RefreshIndicator(
onRefresh: () => context.refresh(productsProvider),
child: ListView(
children: [
for (final product in products.items) ProductItem(product: product),
],
),
);
}
}
There is no ref.onRetry(() {}) as I'm not sure if it's needed. If people ask for it with legitimate use-cases, I'll add it.
I haven't implemented the "gapless playback" flag either. I am not entirely certain that such flag is a good idea, as it could swallow errors. It'll need more thinking.
Looks great Remi!
Why do you say the flag could swallow errors?
Say we retry a future:
If that future fails, we'll likely want to keep showing the previous data instead, and maybe show a snackbar.
So if the "show last valid data" was implemented with a flag, it could swallow errors after the first success.
Maybe we want a custom myFutureProvider.lastValidData.
But on a Future<T>, such provider would return T? instead of the current AsyncValue<T>, so it wouldn't be enough to handle loading/errors on the first render.
Honestly, it's not much of an issue for apps using flutter_hooks. A custom hook could solve this issue easily.
It's more about "What about non-hooks users?"
With hooks, we could do:
final productsProvider = FutureProvider<List<Product>>.autoDispose((ref) async {
final cancelToken = CancelToken();
ref.onDispose(cancelToken.cancel);
return await repository.fetchProducts(cancelToken: cancelToken);
});
// ...
Widget build(context) {
// Listens to the Future created by productsProvider and handles all the refresh logic
AsyncValue<List<Product>> products = useRefreshProvider(
productsProvider,
onErrorAfterRefresh: (err, stack) => Scaffold.of(context).showSnackBar(...),
);
return RefreshIndicator(
onRefresh: () => context.refresh(productsProvider),
child: products.when(
loading: () {
return const SingleChildScrollView(
physics: AlwaysScrollableScrollPhysics(),
child: CircularProgressIndicator(),
);
},
error: (err, stack) {
return SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
child: Text('Oops, something unexpected happened\n$err'),
);
},
data: (products) {
return ListView.builder(
itemCount: products.length,
itemBuilder: (context, index) {
return ProductItem(products[index]);
},
);
},
),
);
}
The list of everything this snippet does related to refresh is:
The question is _how to do that with Consumer_.
To do it without hooks I think an equivalent to BlocListener as suggested in #62 would be needed to handle the "show snackbar" case when an error occurs.
Do you have an idea on how that would look like?
Maybe something like this. I'm not so sure what would be the best way to handle the refresh while keeping the previous value though. Maybe the previously mentioned flag in the FutureProvider could do it.
return ProviderListener(
provider: productsProvider,
listener: (context, products) {
products.maybeWhen(
error: (err, stack) {
Scaffold.of(context).showSnackBar(...),
},
orElse: () {},
);
},
child: Consumer((context, watch) {
final products = watch(productsProvider);
return RefreshIndicator(
onRefresh: () => context.refresh(productsProvider),
child: // Build based on the state of products
);
}),
);
if the refresh fails, keep showing the previously obtained data and show a snackbar with the error
Is this limited to a snackbar? The refresh error can't be dealt with in build?
Since it's clear that a combination of substates can happen at the same time, why choose union/when that forces to fork the UI?
Since it's clear that a combination of substates can happen at the same time, why choose union/when that forces to fork the UI?
The first build when render a "Oops" screen. But after a retry, instead the error will be shown as a snackbar.
So we have this dual behavior where the error handling depend on whether there is data currently visible on the screen.
I'll make a tutorial about that later today, now that I'm done with ScopedProvider.
Most helpful comment
Maybe something like this. I'm not so sure what would be the best way to handle the refresh while keeping the previous value though. Maybe the previously mentioned flag in the
FutureProvidercould do it.