Skip to main content

Use React Query

React Query is a framework-agnostic (even though it has "React" in the name) server data management library. This document contains an overview of why we use React Query, basic examples to get started, advanced examples for more specific use cases, and best practices for working with React Query.

Rationale

There are many different types of state that we need to manage in rich client-side applications. These can include UI state (whether a modal is open or not), user state (who is the current user and what can they do), and generated state (e.g. a Photoshop clone in the browser would have to maintain all the current edits made to an image). But there is another class of application state that is often overlooked: server state. Server state refers to all the data the client has to mirror from backend services.

Many web applications are in essence fancy CRUD interfaces. They show the user the data they have access to, and allow them to operate on that data. Particularly in regard to its list views, Rhapsody would fall into this category of application. For these types of web applications, the majority of state being maintained is server state.

Managing server state involves very different concerns than other types of state, including caching and staleness, handling the network request lifecycle, request deduplication, data normalization, data distribution and subscription throughout the UI, and updating out of date data. Utilizing a library purpose-built for managing server state allows us to read and write code more consistently, implement advanced UX techniques (e.g. prefetching), and have one canonical way of fetching, storing, and reading data from the server.

Basic Usage

React Query delineates between "queries" (read operations) and "mutations" (write operations). There are different hooks for performing both.

Query

React Query provides the useQuery hook for making network requests in React. Outside React, we can use the fetchQuery function.

import { useQuery } from 'react-query'
import { Skeleton } from '@rhythm/loading'
import { api } from '@shared/requests'

async function fetchData() {
const response = await api.get<{ name: string }>('team')
return response.data
}

export function MyComponent() {
const { data, isLoading } = useQuery('team', fetchData)

if (isLoading) return <Skeleton />

return <div>Team {data.name}</div>
}

In this example, our query key is 'team' and our query function is fetchData. The query key tells React Query where in the cache to cache the data returned from the query function. The query function is invoked when the component is mounted, a refetch is triggered or required, etc. React Query handles managing the query function's lifecycle and will populate data with the return value once the query function has resolved.

NOTE: the above example assumes we have implemented a QueryClientProvider higher up in the React component tree.

import { QueryClientProvider } from '@rhapsody/query-client'
import { MyComponent } from './MyComponent'

export const App = () => (
<QueryClientProvider>
<MyComponent />
</QueryClientProvider>
)

Mutation

React Query provides the useMutation hook for handling write operations that don't need their return values cached. It also provides helpful callback handlers if the request succeeds or fails.

import { useMutation } from 'react-query'
import { successToast, errorToast } from '@rhapsody/toasts'
import { api } from '@shared/requests'

function deleteAccount(id: number) {
return api.delete(`accounts/${id}`)
}

export function MyComponent({ accountId }: { accountId: number }) {
const { mutate } = useMutation((id: number) => deleteAccount(id), {
onSuccess() {
successToast('Deleted successfully.')
},
onError(error) {
errorToast('Could not delete. Please try again.')
console.error(error)
},
})

return (
<button onClick={() => mutate(accountId)}>
Delete Account {accountId}
</button>
)
}

Advanced Examples

React Query is highly configurable and can flex to suit many use cases. Below are just a few examples of some of the more advanced functionality React Query supports.

Lifecycle Callbacks

There are times when we need to trigger side effects after a query has run. React Query provides the following callbacks for both useQuery and useMutation:

  • onSuccess: run when a query successfully resolves
  • onError: run when a query results in an error
  • onSettled run when a query either succeeds or results in an error
const { data, isLoading } = useQuery('team', fetchData, {
onSuccess(data) {
console.log('Success!', data)
},
onError(error) {
console.log('Error :(', error)
},
onSettled(data, error) {
console.log('Complete', data, error)
},
})

useMutation also supports an onMutate callback that is fired before a mutation occurs. See additional examples using onMutate in the React Query Optimistic Updates docs.

Handling Unsuccessful HTTP Codes as Errors

React Query neither knows nor cares about HTTP status codes. The responsibility of detecting and throwing an error lies with a query function. @shared/requests commonly used in query functions can be configured to throw on any non-2xx responses. See @shared/requests docs.

Enabling Queries

Not all queries should run on mount. Some queries need to be "enabled" when certain criteria are met. For instance, consider a situation where we must fetch a user's information before we can fetch information about that user's team. This is commonly referred to as waterfall requests. In the example below, the query for team information only runs if the user query is no longer in-flight and if the requests returns the user's team ID.

export function MyComponent({ id }) {
const user = useQuery(['user', id], () => fetchUser(id))
const team = useQuery(
['team', user.data?.team.id],
() => fetchTeam(user.data?.team.id),
{ enabled: !user.isLoading && user.data?.team.id }
)
// ...
}

Using Selectors

Certain queries may return a larger result set than what is needed by the view and we need to filter out unneeded data. We can provide a selector function to queries to filter or transform the data before it is returned from useQuery.

/** 
Assume the dataset returned from the server is an array of the following objects:
[
{ id: 1, name: "Example A", active: true },
{ id: 2, name: "Example B", active: false },
{ id: 3, name: "Example C", active: true },
]
*/

const { data } = useQuery('example', () => fetchData(), {
select(data) {
return data.filter((item) => item.active)
},
})
/**
data === [
{ id: 1, name: "Example A", active: true },
{ id: 3, name: "Example C", active: true },
]
*/

const { data } = useQuery('example', () => fetchData(), {
select(data) {
return data.filter((item) => !item.active)
},
})
/**
data === [
{ id: 2, name: "Example B", active: false },
]
*/

Invalidating the Cache

We can invalidate or "bust" any cached data using its related query key. We do so by interacting with React Query's underlying queryClient instance.

/**
* Assume the following query keys are already in the cache:
* - ['app.example.users', {page: 1}]
* - ['app.example.user', 1]
* - ['app.example.user', 2]
* - 'app.example.currentUser'
*/
import { queryClient } from '@rhapsody/query-client'

// Remove data cached with query key 'app.example.currentUser'
queryClient.invalidateQueries('app.example.currentUser')
// Invalidate a specific compound key
queryClient.invalidateQueries(['app.example.users', { page: 1 }])
// Invalidate both ['app.example.user', 1] and ['app.example.user', 2]
queryClient.invalidateQueries('app.example.user')

Prefetching Queries

Queries can be prefetched to pre-populate the cache before the data is required (or just before the data is required). A common example of this paradigm is preloading a page's data before navigating to it. In the example below, we prefetch the data for Page B if the user mouses over or focuses the link to Page B.

// Page B
function PageB() {
const reportQuery = useQuery('app.example.report', fetchReportData)

return <pre>{reportQuery.data}</pre>
}

// Page A
import { queryClient } from '@rhapsody/query-client'

function PageA() {
const prefetchReport = () =>
queryClient.prefetchQuery('app.example.report', fetchReportData)
return (
<a href="/page-b" onMouseOver={prefetchReport} onFocus={prefetchReport}>
Go To Page B
</a>
)
}

Best Practices

Caching

By default, queries in Rhapsody are cached indefinitely. Teams should decide what the appropriate cache time should be for each query. This value will differ based on product surface, UX considerations, data considerations, etc. The important part is to have the discussion and configure it. We can use the staleTime option with useQuery to set the cache TTL for individual queries:

const { data, isLoading } = useQuery('team', fetchData, {
// 3 minutes in milliseconds
staleTime: 3 * 60 * 1000,
})

Query Keys

Query keys can be either simple, e.g. user, or complex, e.g. ['app.email.emailList', someOtherValues]. As a general rule, include any and all values in your query key that will be passed to your query function. For example, in a list view, include all the parameters that will be sent to the API in your query key. If the API parameters change, React Query will make a new request with the updated parameters and store that data appropriately.

In Rhapsody, query keys should always adhere to the following:

  • Compound keys must start with a string
  • The string part of the key should be namespaced with periods (.) and should start with global, shared, or (most commonly) app . It should be as specific as necessary. For example, the key app.emails.emailList identifies the query as being part of the email app and related to the email list view data set.

See React Query's Query Key docs for additional information on how query keys work.

isFetching vs. isLoading

useQuery provides the boolean flags isFetching and isLoading to check whether the query function is currently in-flight. There are subtle differences between the two. isLoading will only be true the first time the query function is in-flight. Any subsequent query function invocations will not affect isLoading. isFetching will be true every time the query function is in-flight. If one wants to show loading indicators any time a query function is in-flight, use isFetching. To only show on initial load, use isLoading. The latter pattern is particularly helpful when doing background fetching. If isFetching were utilized with background fetching, the UI would unnecessarily go into a loading state and produce a jarring user experience.

Usage with TypeScript

React Query is written in TypeScript and provides types for all of its exports. It is also smart enough to pass the return type from the query function back to the data key useQuery returns.

// Return type of this function is `Promise<number>`
function fetchNumber() {
return Promise.resolve(1)
}

// Return type of this function is `Promise<string>`
function fetchString() {
return Promise.resolve('1')
}

function NumberComponent() {
// `data` is of type `number` automagically 🎉
const { data } = useQuery('data', fetchNumber)
// ...
}

function StringComponent() {
// `data` is of type `string` automagically 🎉
const { data } = useQuery('data', fetchString)
// ...
}

Additional Resources