In this post, I will try to convey some thoughts I've had when working with remote data and UI using React + TypeScript
React Query is a popular library for handling remote data in React applications. Its API is similar to other declarative data-fetching libraries in that it exposes a hook that takes a promise, and returns an object with properties that together make up the current state of the query.
Let's look at a basic example:
const User = () => {
const { data } = useQuery<User, ApiError>('user', getUser)
return (...)
}
The function useQuery
has the following return type:
export interface QueryObserverBaseResult<TData = unknown, TError = unknown> {
data: TData | undefined
dataUpdatedAt: number
error: TError | null
errorUpdatedAt: number
failureCount: number
errorUpdateCount: number
isError: boolean
isFetched: boolean
isFetchedAfterMount: boolean
isFetching: boolean
isLoading: boolean
isLoadingError: boolean
isInitialLoading: boolean
isPaused: boolean
isPlaceholderData: boolean
isPreviousData: boolean
isRefetchError: boolean
isRefetching: boolean
isStale: boolean
isSuccess: boolean
refetch: <TPageData>(
options?: RefetchOptions & RefetchQueryFilters<TPageData>
) => Promise<QueryObserverResult<TData, TError>>
remove: () => void
status: QueryStatus
fetchStatus: FetchStatus
}
Although this extensive object details the state of the query with great granularity, in theory, it also leaves the consumer with a very large number of possible states to represent.
Let's take a look at another example. A kind of bare minimum scenario for representing the current state of a query -- and a common pattern in React components.
What we want to do:
- Show the header at all times.
- If there is an error and we're not fetching, show the error.
- If we're loading, show a spinner.
- If we're fetching after an error, show a spinner
- If none of the above and there is data, show the data.
const User = () => {
const {
data, // TData | undefined,
error, // TError | undefined,
isLoading, // boolean,
isFetching, // boolean
} = useQuery<User, ApiError>('user', fetchUser)
return (
<div>
<Header />
// if error and not fetching, show error
{error && !isFetching && <UserError error={error} />}
// if loading or fetching and error, show spinner
{isLoading || (isFetching && error) && <Spinner />}
// if data and no error, show data
{data && !error && <UserData user={data}>}
</div>
)
}
Here we're using four variables to represent our UI. These are four binary variables, which in theory give us 16 (4^2) possible states. This means there is a big chance we're not covering all of them*.
*NOTE: In this case when using react-query, that's not necessarily true because some states may not be possible (e.g.
!data && isRefetching
). In my opinion, this is another good argument to try to represent states with sum-types instead. Here's a good article on the subject if you're interested.
Why this is a problem
Using this approach quite a lot, I've had a growing inconvenience with it, mainly because:
- We introduce a lot of boolean flags into components and thus adding exponential complexity to them.
- We introduce states with "varying possibility". As mentioned in the note above ☝️, some combinations of states are impossible and nothing in the code successfully conveys that. Only careful inquiry into the documentation of the library will enlighten us about those details.
- We put a lot of responsibility into consumer-land without any type-inferred guidance on how to correctly approach it.
- We lose the concept of exhaustiveness. There is no type-inferred guidance as to how to correctly represent these possible states.
- We're not consolidating the API in relation to our application. The risk of doing something similar but different is non-trivial and may result in different behavior.
An alternative approach
Consolidate the API by transforming the query state into an opinionated representation of it by using discriminated unions
So we're going to transform QueryObserverBaseResult<TData = unknown, TError = unknown>
into our own type AsyncResult<TData, TError>
.
Our type will look like this:
type NotAsked = { status: 'notasked' }
type Loading = { status: 'loading' }
type Refreshing<T> = { status: 'refreshing'; data: T }
type Success<T> = { status: 'success'; data: T }
type Failure<TError, TData> = { status: 'failure'; error: TError; data?: TData }
type AsyncResult<TData, TError> =
| NotAsked
| Loading
| Refreshing<TData>
| Success<TData>
| Failure<TError, TData>
The type QueryObserverBaseResult<TData, TError>
is a lower-level representation of another type, UseQueryResult<TData, TError>
, which is the return type of useQuery
. We'll use that one instead.
So we need a function that maps UseQueryResult<TData, TError>
into our own representation AsyncResult<TData, TError>
. It will have the following signature:
(queryResult: UseQueryResult<TData, TError>) => AsyncResult<TData, TError>
We'll name this function createAsyncResult
. Here's the implementation:
export const createAsyncResult = <TData, TError>(
queryResult: UseQueryResult<TData, TError>
): AsyncResult<TData, TError> | undefined => {
if (queryResult.status === 'error' && !queryResult.isFetching) {
// there is an error and we're not fetching
return {
status: 'failure',
error: queryResult.error,
data: queryResult.data,
}
}
if (queryResult.fetchStatus === 'idle' && !queryResult.isFetched) {
// query has not fired
return { status: 'notasked' }
}
if (
queryResult.status === 'loading' ||
(queryResult.error && queryResult.isFetching)
) {
// we're either initially loading or fetching after an error
return { status: 'loading' }
}
if (queryResult.isFetching && queryResult.data) {
// we're fetching and we have data
return { status: 'refreshing', data: queryResult.data }
}
if (queryResult.status === 'success') {
// we don't have an error, we're not initially loading and we have data
return { status: 'success', data: queryResult.data }
}
// Returning undefined for now if we can't match the state.
// I'd probably be more strict here and throw an error, but for
// the purpose of this example it'll do.
return undefined
}
Now we can use this function when fetching data:
const useUser = () => {
const queryResult = useQuery<User, ApiError>(['user'], fetchUser)
return createAsyncResult(queryResult)
}
And then in our component:
const User = () => {
const asyncResult = useUser()
const deriveComponent = () => {
if (!asyncResult) {
// if createAsyncResult failed to map the query result, return null
return null
}
// using a switch here for exhaustiveness
switch (asyncResult.status) {
case 'notasked':
return null
case 'failure':
return <UserError error={asyncResult.error} />
case 'loading':
return <Spinner />
case 'success':
case 'refreshing':
return <UserData user={asyncResult.data} />
}
}
const component = deriveComponent()
return (
<div>
<Header />
{component}
</div>
)
}
This is better but I think we can still improve the syntax, so we'll go ahead and install a library that can help with that:
npm install ts-pattern
ts-pattern is a great library for pattern matching in TS. We'll use it as follows:
const User = () => {
const asyncResult = useUser()
return (
<div>
<Header />
{match(asyncResult)
.with(undefined, () => null)
.with({ status: 'notasked' }, () => null)
.with({ status: 'failure' }, ({ error }) => <UserError error={error} />)
.with({ status: 'loading' }, () => <Spinner />)
.with({ status: 'success' }, { status: 'refreshing' }, ({ data }) => (
<UserData user={data} />
))
.exhaustive()}
</div>
)
}
We can disregard the first .with(undefined, ...)
for now and take a look at our new type again:
type AsyncResult<TError, TData> =
| NotAsked
| Loading
| Refreshing<TData>
| Success<TData>
| Failure<TError, TData>
and concerning our component ☝️:
- If status is
'notasked'
, it means the query has not been initialized, return null. - If status is
'failure'
, it means state is of typeFailure<TError, TData>
, return the error. - If status is
'loading'
, state is of typeLoading
, return the spinner. - If status is
'success'
or'refreshing'
, our state isSuccess<TData> | Refreshing<TData>
, we have data but might be refreshing, return<UserData />
Notice that in the first case we're matching undefined
with null
. This is because our createAsyncResult
function might return undefined
. If that happens it means the current state of the query is unrepresentable by the rules of our new state, so we return null.
*NOTE: Rather than returning undefined, another approach would be to throw an error. This way you wouldn't have to deal with the result might being undefined. I'm not yet confident this is a good idea though.. :P
Returning null
was implicitly happening before too. Out of all the combinations of states our query can have, we were only matching on some.
Let's revisit our second example:
const User = () => {
const {
data, // TData | undefined,
error, // TError | undefined,
isLoading, // boolean,
isFetching, // boolean
} = useQuery<User, ApiError>('user', fetchUser)
return (
<div>
<Header />
// if error and not fetching, show error
{error && !isFetching && <UserError error={error} />}
// if loading or fetching and error, show spinner
{isLoading || (isFetching && error) && <Spinner />}
// if data and no error, show data
{data && !error && <UserData user={data}>}
</div>
)
}
We know from UseQueryResult<TData, TError>
that we have a bunch of other properties that detail the state of our query. Above we're using some combination of data
, error
, isLoading
, and isRefetching
to decide what to render. If some other combinations of state happen to exist, we're not returning anything, i.e effectively null
.
With our new implementation, on the first .with(undefined, () => null)
, we're explicitly returning null
for that same scenario.
Conclusion
What did we do here? Let's summarize:
- States are now discriminated and this is something our types express.
- Leveraged types in a way that allows TS to give us reasonable guidance.
- We consolidated our data-fetching to one function that decides which states are allowed.
- Impossible states are gone*.
*NOTE: As long as we don't try to combine impossible states in our
createAsyncResult
function. But now you will only make that mistake at one place in your code base.
So far I've enjoyed working with this approach as it eliminates some of the vagueness these libraries introduce. There are most likely situations where this approach would need refactoring.
Here is a sandbox with the code from this article
Thanks to lessp for brainstorming and correcture.
Thanks for reading!
Top comments (5)
I'm curious if you have added a "map" function to use with your AsyncResult type. It's very handy for transforming data. EG: let's say you want to pass in a subset of the returned data to a component, and that component also needs to know whether the data is loading/success/failure.
The type signature of the function is like this:
(result: AsyncResult<TError, TOriginalResponse>) => AsyncResult<TError, TTransformedResponse>
The closest thing I can find in react-query is the
select
parameter. It's not quite the same thing as you can only use it once (you can't transfrom data multiple times as you pass it down through components), but it's better than nothing.And If I'm not misunderstanding you, the
select
param onuseQuery
isn't very fitting to do those transformations as it affects the original return value ofuseQuery
. I'd probably just keep that map function and corresponding types as a util and use it directly when passing props to said component. What do you think?In theory I've seen a need for something like this but I haven't actually needed it yet. But thanks that's cool, I'll check that out! Do you have any more material about the concept? Maybe some examples somewhere or similar?
Nice article.
I found it by googling "impossible states" and "react-query" because I also found this to be a limitation of the extremely popular React Query library which I have no choice but to use at the moment.
I have worked with Elm where this style of programming (making impossible states impossible) is the norm, pattern matching is built in and there is a popular library WebData that uses a type very similar to your AsyncResult.
I totally agree this approach is nicer, and if it was for a personal project I would certainly want to do something like this (but if I was doing a side project I could use Elm instead).
However, I'm torn whether to go down this path with React/TS in a corporate environment. There are just too many hoops you have to jump through to try to get React/JS/TS behave like a functional programming language like Elm, FSharp or Haskell. There are hundreds of FP libraries to choose from for pattern matching, representing useful monads like Maybe and Result, none of which are particularly popular, so chances are you'll pick something different to others in the company. Also, people that are not familiar with this style of programming (the large majority of frontend developers) will find it very hard to read and wonder why you are overcomplicating things. They will get annoyed that they have to learn 10 different competing FP libraries when they just want to get on with their jobs. Often the libraries are not very well documented or unstable which does not help.
I've found it to be more trouble than it's worth, so I've currently decided to "disagree and commit" to doing things the conventional JS ecosystem "multi paradigm" way. It might work if you are the thought leader of a company and are able to get everyone on board but I'm finding it to be just a fight that's not worth fighting as an individual contributor.
I'm hoping the ecosystem evolves to point whether this a norm in the future. Blog posts like this certainly help move things forward - thanks for your effort!
Hey there and thanks for the appreciation, I'm really glad you liked the article!
Yeah, I understand the situation you're describing. I'm fortunate enough to be able to kind of decide this myself in the project I do at work. There is still some weighing in though, trying to keep the threshold rather low for other collaborators in the codebase who are unfamiliar with the "style" while still using and exploring some of these concepts. Some are easier to get into than others, so keeping the bar as low as possible is good so you minimize the risk of deterring people who are not used to the style. What I do in this post here is about as "FP" I've gone in the codebase where this is used, so far at least.
I'm myself pretty new to FP, coming mainly from a JS/TS background. It's definitely baby steps on a big learning curve but I feel like the more I learn about statically typed FP the more I resonate with it.
It's a real bummer the situation you're describing, especially when you know one way is actually better than the other :P
Thanks again!