Why and how we use React query
To start off, read about the motivation for React Query, and why it does not replace redux entirely.
Structural guidelines
Suppose we have a very simple profile API.
import { api } from '@/app'import { AppStatus } from '@/redux'import { useMutation, useQuery, useQueryClient } from 'react-query'export type Profile = {id: numberfirst_name: stringlast_name: stringemail: stringavatar: string}const BASE_URL = 'profile/'export async function create(data: Omit<Profile, 'id'>) {const response = await api.post<Profile>(BASE_URL, data)return response.data}export async function update(profile: Partial<Profile>) {const response = await api.patch<Profile>(`${BASE_URL}/${profile.id}/`, profile)return response.data}export async function retrieve() {const response = await api.get<Profile>(`${BASE_URL}/i/`)return response.data}export const QUERY_KEYS = {retrieve: 'retrieveProfile',update: 'updateProfile',create: 'createProfile',}export function useProfile() {const queryClient = useQueryClient()const getProfile = useQuery(QUERY_KEYS.retrieve, retrieve)// Whenever this function is called, our query will be refetched, either in the foreground or in the backgroundconst invalidateQuery = () => queryClient.invalidateQueries(QUERY_KEYS.retrieve)const updateProfile = useMutation(QUERY_KEYS.update, update, {// This means 'when the request succeeds, invalidate the query'onSuccess: invalidateQuery,// The onMutate runs whenever we start the mutation, eg: call mutation.mutateAsynconMutate: () => AppStatus.set('loading'),// This is the last thing that runs after calling the mutationonSettled(_, err) {if (!err) {AppStatus.set('done')} else {AppStatus.set('idle')}},})const createProfile = useMutation(QUERY_KEYS.create, create, {onSuccess: invalidateQuery,onMutate: () => AppStatus.set('loading'),onSettled(_, err) {if (!err) {AppStatus.set('done')} else {AppStatus.set('idle')}},})return {profile: getProfile.data,// You should define additional properties for the return of the hook// according to necessity, and reuse them on your componentsisAuthenticated: getProfile.isSuccess,createProfile,updateProfile,getProfile}}
1. One function per API route
Notice that each of the functions simply wraps an API call. This ensures we don't get messy with urls, and allows for easy alteration of both types and data formatting should it be necessary. It also has the benefit of facilitating debugging, since for a given route everything will go through the function anyway.
This will integrate nicely with react query since it accepts functions as arguments.
2. Try to keep types accurate
You don't need to be a typescript wizard to at least define the properties of what you expect on the function parameters, and what the API returns. Since this is a pretty central place, the rest of the project should automatically benefit from whatever types are defined here.
If you need some help with Typescript, read the TS 101.
3. Let errors be errors
This depends a lot on how the API responds to failure:
If it returns readable, user friendly errors, just throw an error with the error message or a fallback if there's no message (see create
above).
Otherwise, a generic error message should suffice (see update
above)
These should get intercepted by react query and be really easy to show on the UI.
4. Wrap react-query hooks with a hook for the resource itself
Instead of calling useQuery
and useMutation
on the components themselves, the logic is wrapped inside the useProfile hook. This will help in DRYing component code as well as preventing mistakes such as incorrectly referencing query keys or mismanaging load states.
This is great for handling unique resources such as the user's profile, but may quickly get messy and/or repetitive for list like data, which is quite common in most apps.
Pagination
Suppose we have a generic list of items. Our API functions don't change much, but the hook does.
Defining our hook with usePagination
Using the pagination hook, our API should look like this.
import { usePagination } from '@codeleap/common'const LIMIT = 20export function useItems(itemId?: string) {const pagination = usePagination('items',{// These are the functions that actually make the requestonList: api.listItems,onCreate: api.createItem,onRemove: api.removeItem,onUpdate: api.updateItem,onRetrieve: retrieveItem,// With this, we can run arbitrary code before and after mutationsbeforeMutate: () => AppStatus.set('loading'),afterMutate: (operation, result) => AppStatus.set(result.status === 'error' ? 'idle' : 'done'),// This is used internally to compare and index itemskeyExtractor: item => item.id,// This triggers a fetch for a single item and inserts into the listwhere: [itemId],sort(a, b) {return new Date(b.created_datetime).getTime() - new Date(a.created_datetime).getTime()},// This is the limit our `api.listItems` function will receive. It's also used internally to determine initalStatelimit: LIMIT,// This will be used for automatically getting the text for flatlistsitemName: 'CoolItem',// The hook can't possibly cover every single use case in existance, so you may override it on a query per query basis.overrides: {list: {refetchOnMount: false},},})return pagination}
Using the hook
And we may use it in components like so
function MyComponent() {const itemsApi = useItems()function onRemove(item){itemsApi.queries.remove.mutateAsync(item.id)}function onCreate(values){itemsApi.queries.create.mutateAsync(item)}return <List// This includes the logic for refresh,// showing pagination indicators, fetching more pages and other stuff{...itemsApi.flatListProps}renderItem={({ item }) => {return <ItemCardkey={item.id}onRemove={() => onRemove(item)}item={item}/>}}ListHeaderComponent={() => <CreateItemForm onSubmit={onCreate} />}/>}