Avoiding boolean flags and impossible states when using declarative data fetching with React and Typescript
Andreas Lundqvist
Posted on October 26, 2022
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!
Posted on October 26, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.