iTranslated by AI
Understanding Riverpod's AsyncValue through its Structure
Flutter's Riverpod package provides a class called AsyncValue.
This is an object passed to the Widget side (or another Provider) when state generation involves asynchronous processing. It is designed to hold detailed information about the current status of that process.
For example:
@riverpod
Future<List<Articles>> fetchArticles() async {
// Process to fetch the article list from the database
return await request();
}
If there is a Provider like this for fetching article list data of type List<Article> from a database, when you call ref.watch(fetchArticlesProvider) on the Widget side:
class ArticleListPage extends ConsumerWidget {
@overrie
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<List<Article>> value = ref.watch(fetchArticlesProvider);
// ... Process to create the list screen
}
}
an object of type AsyncValue<List<Article>> is returned.
This AsyncValue object contains information about which state the asynchronous process, such as data fetching, is in:
- Loading
- Data (Fetching completed)
- Error (An error occurred during processing)
The caller can use the .when() method to write logic for each specific case.
class ArticleListPage extends ConsumerWidget {
@overrie
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(fetchArticlesProvider);
return value.when(
loading: () => const CircularProgressIndicator(),
data: (data) => _ArticleListView(data),
error: (error, stacktrace) => _ErrorView(error),
);
}
}
If the status is "loading" at the time the Widget's build() method is called, the value returned by when() will be the result of executing the function passed to loading.
When the process subsequently completes, the Widget's build() method is called again via a rebuild. This time, the value returned by .when() will be the result of executing the function passed to data.
In the code above, the object returned by the .when() method is used directly as the Widget returned by the build() method. As a result, the Widget returned switches with each rebuild, allowing the UI to reflect changes such as a "loading spinner" and the "list screen after fetching" to the user.
This covers the basic usage of AsyncValue, but things get a bit more complex when dealing with it in actual app development.
In this article, I would like to explore what kind of content AsyncValue holds and how we, as app developers, should handle it.
Note that the version of Riverpod at the time of publishing this article is 2.4.3.
Additionally, due to a lack of research, this article will only cover cases where the state generation is a Future, and I will omit the discussion regarding Stream cases. Please be aware of this.
Three Subclasses and Three Fields
Looking at the documentation and code, AsyncValue itself is an abstract class, and three classes inheriting from it—AsyncData, AsyncError, and AsyncLoading—are defined.[1]
As the names suggest, these three classes seem to represent the three aforementioned states, but in reality, the situations to consider when performing "asynchronous processing" are not simple enough to be covered by just those three.
This is evident from the three fields that AsyncValue holds (and thus the three subclasses also hold): value, isLoading, and error.
Since these parent class fields can be held by each subclass like AsyncLoading, it is possible to represent composite states such as "an AsyncData where isLoading is true" or "an AsyncLoading that has a value."
In what cases are these "composite states" necessary?
State Transitions of Asynchronous Processing
Here, let's set aside AsyncValue and imagine state transitions in general asynchronous processing (for example, fetching data from a server).
When considering the case where a target page is displayed and the target data is fetched "for the first time," the flow of the process looks like the following image:
It is very simple. It starts with "Processing", and if it completes normally, it becomes "Data Fetching Completed"; if an error occurs along the way, it becomes "Error Occurred."
However, state management might not end with the initial fetch. This is because there are cases where the data needs to be refetched or reacquired under different conditions later.
The image below illustrates this:
Even if the process is completed once and data is acquired (or an error occurs), the app's requirements do not end there.
For example, as with pull-to-refresh, you might execute the same process again without changing conditions to refetch data, or you might change conditions like categories to fetch different data.
You also need to consider what you want to display on the screen during the second and subsequent processes.
Various patterns are possible: whether to display the same loading screen as the initial load as "loading," or to keep the already acquired data and perform the fetching process in the background, or to display only the error if an error occurs, or to display what was acquired last time, and so on.
And as expressed by the formula UI = f(State), in Flutter, you must manage the data to realize those UI patterns as "states" somewhere for as many UI patterns as you want to achieve. This is where AsyncValue comes in.
How to Handle AsyncValue
As discussed, state management involving asynchronous processing can involve various situations, requiring multiple fields in the Dart code to represent them in the UI. AsyncValue is essentially a single versatile object designed to represent these diverse situations.
With that in mind, let's explore how to use it.
Using the AsyncValue Object
First, let's consider the "consumer side"—for instance, when using ref.watch in a Widget.
In many cases, after obtaining an AsyncValue object, you likely use the .when() method to code the Widget you want to display for each state.
class ArticleListPage extends ConsumerWidget {
@overrie
Widget build(BuildContext context, WidgetRef ref) {
final value = ref.watch(fetchArticlesProvider);
return value.when(
loading: () => const CircularProgressIndicator(),
data: (data) => _ArticleListView(data),
error: (error, stacktrace) => _ErrorView(error),
);
}
}
To be precise, .when() is a method that selects and calls exactly one of the functions passed to the loading, data, and error arguments.
Which function is called is determined by the content of the AsyncValue object (along with any flags passed). The implementation itself consists of simple conditional branching, as shown below:
R when<R>({
bool skipLoadingOnReload = false,
bool skipLoadingOnRefresh = true,
bool skipError = false,
required R Function(T data) data,
required R Function(Object error, StackTrace stackTrace) error,
required R Function() loading,
}) {
if (isLoading) {
bool skip;
if (isRefreshing) {
skip = skipLoadingOnRefresh;
} else if (isReloading) {
skip = skipLoadingOnReload;
} else {
skip = false;
}
if (!skip) return loading();
}
if (hasError && (!hasValue || !skipError)) {
return error(this.error!, stackTrace!);
}
return data(requireValue);
}
As you can see, it doesn't perform complex tasks like "detecting changes in AsyncValue"; it simply uses conditional branches to select which function should be called.
This conditional branching is divided into three main blocks. Let's look at the code for each: whether to call loading(), whether to call error(), or whether to call data().
Whether loading() should be called
First, a check is performed to see if the process is "loading."
As explained so far, "whether it is loading" is not determined solely by "whether the object is an instance of AsyncLoading." Let's confirm what constitutes "loading" by looking at the implementation.
if (isLoading) {
First, it checks if isLoading == true. While AsyncLoading always has this field set to true, AsyncData and AsyncError can also have this value as true.
This happens when "re-fetching under the same conditions" from the second time onwards.
When ref.invalidate(fetchArticlesProvider) is called at a timing such as a pull-to-refresh or a button tap, fetchArticlesProvider does not discard all information. Instead, it retains the last acquired content while setting the isLoading flag to true.
In other words, if the previous result was "Data Fetching Completed," the instance remains AsyncData, and if it was "Error Occurred," it remains AsyncError. Both are rebuilt with isLoading set to true, so this block is entered even for cases other than AsyncLoading.
bool skip;
if (isRefreshing) {
skip = skipLoadingOnRefresh;
The next three lines are here.
A skip flag is defined, and true or false is assigned in the subsequent steps.
The first check is whether isRefreshing == true.
Looking at the implementation, isRefreshing is a getter as follows:
bool get isRefreshing =>
isLoading && (hasValue || hasError) && this is! AsyncLoading;
It is defined as "the instance is NOT AsyncLoading," " isLoading == true," AND "previously acquired data or an error exists."
In other words, the "re-fetch under the same conditions from the second time onwards" mentioned earlier fits exactly this situation.
Just as the documentation often uses pull-to-refresh to explain this, it is best understood as isRefreshing == true when "refreshing with ref.invalidate() or ref.refresh() (i.e., re-fetching without changing conditions)."
This means that if isRefreshing is true and the skipLoadingOnRefresh passed as an argument is true, then skip becomes true.
Incidentally, as you can see by reading further into the code, this skip flag is a flag that indicates "whether to skip the call to loading()". In other words, you set this flag to true when you want error() or data() to be called instead of loading(), even if it is in a refreshing state.
However, looking at the argument definitions, skipLoadingOnRefresh is true by default, so during a refresh, loading() is not called, and if there is no error, data() is called.
skipLoadingOnReload, mentioned later, follows the same logic, but note that its default value is false.
Now, returning to the logic, the next two lines check if isReloading == true.
} else if (isReloading) {
skip = skipLoadingOnReload;
isReloading is a state distinct from isRefreshing and becomes true when "re-fetching with different conditions". The implementation is as follows:
bool get isReloading => (hasValue || hasError) && this is AsyncLoading;
The (hasValue || hasError) part is the same as isRefreshing, checking if "previously acquired data or an error exists." The difference from isRefreshing is that it includes "whether the instance is AsyncLoading."
This state occurs, for example, when a Provider performing data fetching watches another Provider as shown below, and conditionProvider changes, causing fetchArticles() to be called again.
@riverpod
Future<List<Articles>> fetchArticles() async {
final condition = await ref.watch(conditionProvider);
// Process to fetch the article list from the database
return await request(condition);
}
It is also the same when you directly assign AsyncLoading to the state in any method of a Notifier, such as state = AsyncLoading().[2]
In such a situation, isReloading == true, and if the argument skipLoadingOnReload is true, skip becomes true; if it is false, skip also becomes false.
If it doesn't fall into any of the isRefreshing or isReloading checks, skip is forced to false as follows:
} else {
skip = false;
}
if (!skip) return loading();
If skip == false, it matches the final if(!skip) condition, so loading() is called and its return value becomes the return value of .when() itself.
Whether error() should be called
While loading() had complex branching, the logic for error() is relatively simple.
if (hasError && (!hasValue || !skipError)) {
return error(this.error!, stackTrace!);
}
hasError, as the name suggests, is determined by "whether the error field has a value."
bool get hasError => error != null;
This is true not only when the instance is AsyncError, but also when "the previous result was an error and it is currently reloading or refreshing" because the error state is preserved.
In other words, error() is called when "error information exists" and "data is not yet available or the argument skipError is at its default value of false."
Whether data() should be called
If the state is neither "loading" nor "error occurred," it is considered "data fetching completed," and data() is called.
return data(requireValue);
requireValue is a getter that simply casts and retrieves value, representing the "already acquired data." However, if value is null, it is implemented to throw an exception.
T get requireValue {
if (hasValue) return value as T;
if (hasError) {
throwErrorWithCombinedStackTrace(error!, stackTrace!);
}
throw StateError(
'Tried to call `requireValue` on an `AsyncValue` that has no value: $this',
);
}
So, we have gone through the implementation of .when(), and it turned out to be quite lengthy.
Despite the detailed explanation, my understanding is that it is designed so that the function called aligns with your intuition in most situations. However, if loading() or error() is called at an unexpected time, it would be beneficial to review this logic flow and the values of the arguments.
There are other variants of when(), such as maybeWhen() and whenOrNull(). These essentially call when() internally and only differ in behavior when data is absent or when a function for a specific case is not provided. As long as you understand when(), there should be no problem.
Just one thing to note: there is a similar method called whenData(), but its purpose is slightly different (I will omit the details here).
Rewriting to Dart 3 switch-case
There is talk that the behavior of when() can be rewritten using the switch-case introduced in Dart 3.
// Before
asyncValue.when(
data: (value) => print(value),
error: (error, stack) => print('Error $error'),
loading: () => print('loading'),
);
// After
switch (asyncValue) {
case AsyncData(:final value): print(data);
case AsyncError(:final error): print('Error $error');
case _: print('loading');
}
However, as we have seen so far, the behavior of when() is not determined solely by the type of the subclass; it can change depending on the object's properties and the flags passed as arguments. Therefore, it is not as simple as a mechanical conversion.
Reflecting on the logic of when(), simple rewriting seems possible when the skipLoadingOn and skipError flags are left at their default values. Otherwise, you must be careful because a discrepancy can occur between the subclass type and the function that should be called.
Looking at the GitHub issue, creating documentation for migration methods that consider various patterns is being discussed, so it might be best to wait for that page to be completed.
The Side Generating AsyncValue Objects
So far, we have looked at the consumer side of AsyncValue. Next, let's look at the side that generates AsyncValue objects—the Provider side.
Use Providers Designed to Generate AsyncValue
The first thing to keep in mind is that the generation of AsyncValue should always be done using Providers specifically intended for it. Specifically, this refers to four types of Providers: FutureProvider, AsyncNotifierProvider, StreamProvider, and StreamNotifierProvider.
Conversely, it is best to avoid assigning manually created AsyncValue objects to state within Providers that are currently deprecated, such as StateNotifierProvider, ChangeNotifierProvider, or StateProvider.
The reason for this is that the four Providers mentioned above internally handle the generation of AsyncValue objects with appropriate content based on the situation, whereas in other deprecated Providers, the AsyncValue object assigned to state is passed directly to the Widget side. This means it won't automatically reflect states like "it's the second time, so isRefreshing == true," and depending on the implementation, the way it is handled may deviate from the standard approach expected by the official documentation.
For example, looking at the asyncTransition() method, which is part of the internal logic for generating AsyncValue, we see the following:
void asyncTransition(
AsyncValue<T> newState, {
required bool seamless,
}) {
final previous = getState()?.requireState;
if (previous == null) {
setState(newState);
} else {
setState(
newState.copyWithPrevious(
previous,
isRefresh: seamless,
),
);
}
}
If the previous AsyncValue was null (i.e., the first time), the received AsyncValue object is set as is. On the other hand, if it's not null, logic is written to merge it with the existing AsyncValue object using copyWithPrevious().
What copyWithPrevious() actually does depends on the implementation of each subclass, but basically, it performs actions like "inheriting previous errors or data if it's loading" or "including only the latest content if data fetching is complete."[3]
In contrast, StateNotifierProvider and similar do not perform any such processing. Since the AsyncValue object coded and generated by the app developer is passed directly via ref.watch(), it becomes our responsibility to use copyWithPrevious() appropriately to ensure behavior consistent with the documentation.
To achieve "appropriately," you would ultimately need to trace and understand all internal implementations like AsyncNotifier. Instead of doing that, it's faster and safer to rewrite the logic using AsyncNotifierProvider or FutureProvider.
Objects to Assign to state
As mentioned earlier, as long as you are using AsyncNotifier, AsyncValue objects assigned to state do not reach the Widget as-is. Instead, they are merged into appropriate content internally before reaching the Widget, so there isn't much extra to worry about.
For example, if you want to set the state to "loading," you can just assign an object generated by AsyncLoading() to state. Once data is acquired, wrap it in AsyncData() and assign it to state.
Future<void> fetchExtraArticles() async {
// Set to loading
state = AsyncLoading();
// Some processing
final List<Article> extra = await fetchExtra();
state = AsyncData([...state, ...extra]);
}
By doing this, an AsyncValue object is generated by merging the AsyncLoading object assigned to state with the previous AsyncValue object, triggering a rebuild and updating the UI with the appropriate content on the Widget side.
Even when the process completes and data is acquired, simply generating an AsyncData object (passing the final data as an argument) and assigning it to state will allow the internal logic to generate the appropriate AsyncValue object.
Keeping in mind that the system handles complex tasks like "inheriting the previous state and determining whether it's a refresh or a reload" internally, you only need to assign the minimum necessary information when manually assigning AsyncValue objects to state.
By the way, if you want to assign an AsyncError when an error occurs, AsyncValue.guard() is useful.
state = await AsyncValue.guard(
() async {
final List<Article> extra = await fetchExtra();
return [...state, ...extra];
},
);
By passing a Future process to AsyncValue.guard(), an AsyncData object with the returned value is generated on success, and an AsyncError object with the error information is generated on failure. Thus, simply setting that to state allows an AsyncValue object merged with the appropriate content to be passed to the UI.
Summary
Above, we have examined the structure of the AsyncValue class, how its objects are generated, and how they can be handled by looking at the implementation.
As can be seen from the official documentation[4], Riverpod is a package that focuses on solving problems associated with "state management involving asynchronous processing."
The AsyncValue and the internal processing that generates it are meant to proactively solve problems that typically occur in "state management involving asynchronous processing." By making sure to implement it in a way that correctly follows this mechanism, you can avoid oversight and enjoy the benefits of solving problems in the "standard" way.
Conversely, if the "standard" way clearly does not fit the requirements of the app in front of you, it might be better to reconsider the use of this mechanism (and even the Riverpod package itself).
It's no exaggeration to say that understanding AsyncValue accounts for more than half of the understanding of Riverpod, so it's a good idea to deepen your understanding as much as possible by looking at the documentation, source code, and behavior.
I have published a sample app to check how to handle AsyncValue, so I'd be happy if it helps.
-
In Riverpod 3, the
AsyncValueclass will become asealed class, so defining other subclasses for specific situations is not intended. ↩︎ -
In reality,
stateis a setter, so the process of merging the receivedAsyncLoadingwith the previousAsyncValueis performed internally. ↩︎ -
In fact,
copyWithPrevious()also has a flag calledisRefreshas an argument, and the behavior changes depending on its truth value, so it is quite complex. ↩︎ -
The sample code on the top page is a data-fetching process involving HTTPS requests rather than a counter, and the Why Riverpod page also mostly talks about state management through asynchronous processing involving communication with a server. ↩︎
Discussion