In the first post of the series I explained how Data Transfer Objects can make code more immune to FE/BE contract changes. This time, I’d like to share a pattern that helps enforce proper handling of error and loading states when working with REST API’s. We’ve been successfully using this pattern at Sumo Logic’s UI for some time now (credits to Tomasz Olędzki who introduced AsyncResult
to our codebase a few years back).
The examples in this post are based on React but these ideas can be used with any UI framework.
Complete examples available in this repo on GitHub.
Issues with naive data fetching
Let’s take a look at a React component that fetches some data from the backend and displays the result. A typical (and somewhat naive) implementation could look like this:
1 | export const CatFact: React.FC = () => { |
There are several issues with this code:
- No error handling - if the backend call fails, we won’t even learn about it.
- No loading state - if the call takes long time to complete, the user will keep seeing empty page.
- Possible state update after the component is unmounted (out of scope of this article).
The AsyncResult
pattern can help with the first two issues.
Introducing AsyncResult
The root cause behind the first two issues is that the author of the code forgot about these two cases. This is understandable - nobody is perfect and it’s easy to neglect such things when you’re focused on solving a complex problem or are simply very busy. Thankfully, we can employ the type system to remind us about handling these cases.
AsyncResult
is a simple type that represents the result of an asynchronous operation (a fetch call in our case). It’s defined as a discriminated union where each member represents a possible state in which an asynchronous operation could be: in progress, success or failure.
1 | export type AsyncResult<TResult, TError = unknown> = |
AsyncSuccess
stores the value returned by a successful operation. AsyncFailure
stores the error that caused the operation to fail. AsyncInProgress
doesn’t store any additional information.
In order to make working with this new type more convenient, it’s useful to define some utility functions for converting regular values to AsyncResult
:
1 | export const asAsyncSuccess = <TResult>( |
Now let’s see how we can apply this new type to address the issues mentioned at the beginning.
Addressing the issues of the naive approach with AsyncResult
Let’s start by replacing the value stored in the component state with AsyncResult
:
1 | export const CatFact: React.FC = () => { |
As we can see, TypeScript prevents us from accessing the value
property of catFact
. This is because we’re trying to access the result of an asynchronous operation yet we don’t even know whether it has already completed nor whether it completed successfully. In other words, the type system forces us to check the state of the operation before we access its result. While doing this, we get reminded that we should actually handle the other possible states.
1 | export const CatFactAsyncResult: React.FC = () => { |
There is a one missing piece - the code handles the failure
state, but right now catFact
will never be set to an instance of AsyncFailure
. We can fix this but providing the onrejected
callback to the Promise
returned from fetch
. The final code will look like this:
1 | export const CatFactAsyncResult: React.FC = () => { |
What’s so great about this type is that it makes it explicit that an async operation does not always have to finish successfully. By doing so, it forces the developer to take care of all possible outcomes.
Generic fetch
utility
As you can see, this approach introduces a little bit of boilerplate. Thankfully, we can easily abstract it in a generic utility hook for fetching data from the backend.
1 | export const useGetResult = <TResult, TError = unknown>(url: string) => { |
Now, the end result gets much cleaner:
1 | export const CatFactAsyncResultHook: React.FC = () => { |
Additional bonus of using such an abstraction is that you can incorporate more good patterns into it. For example, you could leverage AbortController
to address the third issue mentioned in the first paragraph: possible state update after the component is unmounted.
Other applications
In the above example we focused on a scenario where data fetching happens in a React component. In a real-life app you’d often use Redux for storing the results of backend calls or use a dedicated data fetching library. Let’s see if AsyncResult
can prove useful in such cases as well.
Redux
You can definitely make use of the AsyncResult
type in Redux. When storing fetch results in Redux, you may end up having fields like isLoading
or errorMessage
in the state. With AsyncResult
, you don’t need such fields as you can represent the status of the operation with a single field.
What’s more, the type allows you to simplify your actions. In a typical scenario, you’d have a separate action for request start, success and failure. With AsyncResult
a single action type is sufficient as you can convey all this information in the action’s payload.
Data fetching libraries
Those of you who have used React Query may find the pattern described in this article familiar. In fact, the type returned by useQuery
is an extended version of the AsyncResult
type.
1 | const result: UseQueryResult<number> = useQuery(["number"], () => |
Similarly, the type used to represent query result in RTK Query is also based on this pattern.
1 | const { data, error, isLoading } = useGetPokemonByNameQuery("bulbasaur"); |
This only proves that the AsyncResult
pattern is useful. Data fetching libraries such as React Query provide utils for fetching data that incorporate multiple best practices and the AsyncResult
pattern is one of them. Of course, these libraries do much more (caching, cancellation, etc.).
Is AsyncResult
type useful at all when working with these libraries then? Actually, I think it is. One thing I lack from the types returned by these libraries is composability. However, we can easily add composability to the AsyncResult
type. I’ll talk about some real life applications of this approach in a future post.
Summary
In this post we learned about some of the benefits of the AsyncResult
type. This is yet another great example of how we can leverage the type system to enforce some rules that actually improve the overall user experience.
When working with existing large codebases, it’s not always easy to introduce a proper data fetching library. In such cases, you may use the AsyncResult
pattern to get at list some of the benefits of these libraries without introducing any new dependencies. I encourage you to experiment with it in your project.
Did you like this TypeScript article? I bet you'll also like my book!
⭐️ Advanced TypeScript E-book ⭐️