Building The Real App With React Query
If you have ever built React applications that use asynchronous data you probably know how annoying it could be to handle different states (loading, error, and so on), share the state between components using the same API endpoint, and keep the synchronized state in your components.
In order to refresh data, we should do a lot of actions: define useState
and useEffect
hooks, fetch data from the API, put the updated data to the state, change the loading state, handle errors, and so on. Fortunately, we have React Query, i.e. a library that makes fetching, caching, and managing the data easier.
Benefits Of Using The New Approach
React Query has an impressive list of features:
- caching;
- deduping multiple requests for the same data into a single request;
- updating “out of date” data in the background (on windows focus, reconnect, interval, and so on);
- performance optimizations like pagination and lazy loading data;
- memoizing query results;
- prefetching the data;
- mutations, which make it easy to implement optimistic changes.
To demonstrate these features I’ve implemented an example application, where I tried to cover all cases for those you would like to use React Query. The application is written in TypeScript and uses CRA, React query, Axios mock server and material UI for easier prototyping.
Demonstration The Example Application
Let’s say we would like to implement the car service system. It should be able to:
- log in using email and password and indicate the logged user;
- show the list of next appointments with load more feature;
- show information about one particular appointment;
- save and view changes history;
- prefetch additional information;
- add and amend required jobs.
Client-Side Interaction
As we don’t have a real backend server, we will use axios-mock-adapter
. I prepared some kind of REST API
with get/post/patch/delete endpoints. To store data, we will use fixtures. Nothing special — just variables which we will mutate.
Also, in order to be able to view state changes, I’ve set the delay time as 1 second per request.
Preparing React Query For Using
Now we are ready to set up React Query. It’s pretty straightforward.
First, we have to wrap our app with the provider:
const queryClient = new QueryClient();
ReactDOM.render(
<React.StrictMode>
<Router>
<QueryClientProvider client={queryClient}>
<App />
<ToastContainer />
</QueryClientProvider>
</Router>
</React.StrictMode>,
document.getElementById('root')
);
In QueryClient()
, we could specify some global defaults.
For easier development, we will create our own abstractions for React Query hooks. To be able to subscribe to a query we have to pass a unique key. The easiest way to use strings, but it’s possible to use array-like keys.
In the official documentation, they use string keys, but I found it a bit redundant as we already have URLs for calling API requests. So, we could use the URL as a key, so that we don’t need to create new strings for keys.
However there are some restrictions: if you are going to use different URLs for GET/PATCH
, for example, you have to use the same key, otherwise, React Query will not be able to match these queries.
Also, we should keep in mind that it’s important to include not only the URL but also all parameters which we are going to use to make requests to the backend. A combination of URL and params will create a solid key which the React Query will use for caching.
As a fetcher, we will use Axios where we pass a URL and params from queryKey
.
export const useFetch = <T>(
url: string | null,
params?: object,
config?: UseQueryOptions<T, Error, T, QueryKeyT>
) => {
const context = useQuery<T, Error, T, QueryKeyT>(
[url!, params],
({ queryKey }) => fetcher({ queryKey }),
{
enabled: !!url,
...config,
}
);
return context;
};
export const fetcher = <T>({
queryKey,
pageParam,
}: QueryFunctionContext<QueryKeyT>): Promise<T> => {
const [url, params] = queryKey;
return api
.get<T>(url, { params: { ...params, pageParam } })
.then((res) => res.data);
};
Where [url!, params]
is our key, setting enabled: !!url
we use for pausing requests if there is no key (I’ll talk about that a bit later). For fetcher we could use anything — it doesn’t matter. For this case, I chose Axios.
For a smoother developer experience, it’s possible to use React Query Devtools by adding it to the root component.
import { ReactQueryDevtools } from 'react-query/devtools';
ReactDOM.render(
<React.StrictMode>
<Router>
<QueryClientProvider client={queryClient}>
<App />
<ToastContainer />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
</Router>
</React.StrictMode>,
document.getElementById('root')
);
Nice one!
Authentication
To be able to use our app, we should log in by entering the email and password. The server returns the token and we store it in cookies (in the example app any combination of email/password works). When a user goes around our app we attach the token to each request.
Also, we fetch the user profile by the token. On the header, we show the user name or the loading if the request is still in progress. The interesting part is that we can handle a redirect to the login page in the root App
component, but show the user name in the separate component.
This is where the React Query magic starts. By using hooks, we could easily share data about a user without passing it as props.
App.tsx
:
const { error } = useGetProfile();
useEffect(() => {
if (error) {
history.replace(pageRoutes.auth);
}
}, [error]);
UserProfile.tsx
:
const UserProfile = ({}: Props) => {
const { data: user, isLoading } = useGetProfile();
if (isLoading) {
return (
<Box display="flex" justifyContent="flex-end">
<CircularProgress color="inherit" size={24} />
</Box>
);
}
return (
<Box display="flex" justifyContent="flex-end">
{user ? `User: ${user.name}` : 'Unauthorized'}
</Box>
);
};
And the request to the API will be called just once (it is called deduping requests, and I’ll talk about it a bit more in the next section).
Hook to fetch the profile data:
export const useGetProfile = () => {
const context = useFetch<{ user: ProfileInterface }>(
apiRoutes.getProfile,
undefined,
{ retry: false }
);
return { ...context, data: context.data?.user };
};
We use the retry: false
setting here because we don’t want to retry this request. If it fails, we believe that the user is unauthorized and do the redirect.
When users enter their login and password we send a regular POST
request. Theoretically, we could use React Query mutations here, but in this case, we don’t need to specify const [btnLoading, setBtnLoading] = useState(false);
state and manage it, but I think it would be unclear and probably over complicated in this particular case.
If the request is successful, we invalidate all queries to get fresh data. In our app it would be just 1 query: user profile to update the name in the header, but just to be sure we invalidate everything.
if (resp.data.token) {
Cookies.set('token', resp.data.token);
history.replace(pageRoutes.main);
queryClient.invalidateQueries();
}
If we wanted to invalidate just a single query we would use queryClient.invalidateQueries(apiRoutes.getProfile);
.
More About Deduping Requests
Let’s assume that we have two different (or even the same) components on the page which use the same API endpoint. Usually, we would have to make two requests, which are actually the same, and it’s just a waste of the backend resources. Using React Query, we could dedupe API calls with the same params. This means that if we have components that call the same requests, the request will be made just once.
In our app, we have two components: showing total appointments and the list.
Total appointments component:
const UsersSummary = () => {
const { data: list, isLoading } = useGetAppointmentsList();
if (!isLoading && !list) {
return null;
}
return (
<Box mb={2}>
<Card>
<Box p={2}>
<Typography>
Total appointments:{' '}
{isLoading ? (
<Skeleton
animation="wave"
variant="rectangular"
height={15}
width="60%"
/>
) : (
list!.pages[0].count
)}
</Typography>
</Box>
</Card>
</Box>
);
};
Users list component:
const UsersList = () => {
const {
data: list,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetAppointmentsList();
return (
<>
<Card>
{isLoading ? (
<List>
<Box mb={1}>
<UserItemSkeleton />
</Box>
<Box mb={1}>
<UserItemSkeleton />
</Box>
<Box mb={1}>
<UserItemSkeleton />
</Box>
</List>
) : (
<List>
{list!.pages.map((page) => (
<React.Fragment key={page.nextId || 0}>
{page.data.map((item) => (
<UserItem
key={item.id}
id={item.id}
name={item.name}
date={item.appointment_date}
/>
))}
</React.Fragment>
))}
</List>
)}
</Card>
{hasNextPage && (
<Box mt={2}>
<Button
variant="contained"
color="primary"
onClick={() => {
fetchNextPage();
}}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load more users'}
</Button>
</Box>
)}
</>
);
};
They use the same hook useGetAppointmentsList()
where we send the request to the API. As we could see, the request GET /api/getUserList
was called just once.
Load More List
In our app, we have an infinite list with a Load more
button. We cannot implement that using a regular useQuery
hook, that’s why we have useInfiniteQuery
hook, it makes it possible to handle pagination, using fetchNextPage
function.
export const useGetAppointmentsList = () =>
useLoadMore<AppointmentInterface[]>(apiRoutes.getUserList);
We have our own abstraction for the React Query hook:
export const useLoadMore = <T>(url: string | null, params?: object) => {
const context = useInfiniteQuery<
GetInfinitePagesInterface<T>,
Error,
GetInfinitePagesInterface<T>,
QueryKeyT
>(
[url!, params],
({ queryKey, pageParam = 1 }) => fetcher({ queryKey, pageParam }),
{
getPreviousPageParam: (firstPage) => firstPage.previousId ?? false,
getNextPageParam: (lastPage) => {
return lastPage.nextId ?? false;
},
}
);
return context;
};
It’s pretty much the same that we have for useFetch
hook, just here we specify getPreviousPageParam
and getNextPageParam
functions, based on the API response, and also we pass pageParam
property to the fetcher function.
const UsersList = () => {
const {
data: list,
isLoading,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useGetAppointmentsList();
return (
<>
<Card>
{isLoading ? (
<List>
<Box mb={1}>
<UserItemSkeleton />
</Box>
<Box mb={1}>
<UserItemSkeleton />
</Box>
<Box mb={1}>
<UserItemSkeleton />
</Box>
</List>
) : (
<List>
{list!.pages.map((page) => (
<React.Fragment key={page.nextId || 0}>
{page.data.map((item) => (
<UserItem
key={item.id}
id={item.id}
name={item.name}
date={item.appointment_date}
/>
))}
</React.Fragment>
))}
</List>
)}
</Card>
{hasNextPage && (
<Box mt={2}>
<Button
variant="contained"
color="primary"
onClick={() => {
fetchNextPage();
}}
disabled={isFetchingNextPage}
>
{isFetchingNextPage ? 'Loading more...' : 'Load more users'}
</Button>
</Box>
)}
</>
);
};
useInfiniteQuery
hook has several additional fields like fetchNextPage
, hasNextPage
, isFetchingNextPage
, which we could use to handle our load more list. And methods fetchNextPage
, fetchPreviousPage
.
Background Fetching Indicator/Refetching
One of the most interesting features for me is refetching data if we change window focus, like switching between tabs. For example, it could be useful if data could be changed by several authors. In this case, if we keep the browser tab opened we don’t have to reload the page. We will see the actual data when we focus on the window. Moreover, we could use a flag, to indicate that fetching is in progress. React Query has several settings in case you don’t need it:
refetchInterval
,refetchIntervalInBackground
,refetchOnMount
,refetchOnReconnect
,refetchOnWindowFocus
.
Also it’s possible to disable/enable options globally:
const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
},
},
});
To indicate re-fetching we have isFetching
flag to show loading state:
Making Conditional Requests
As we use hooks to fetch the data it could be confusing how we can avoid making requests. As you know, we cannot use conditional statements with hooks, for example we cannot code like that:
if (data?.hasInsurance) {
const { data: insurance } = useGetInsurance(
data?.hasInsurance ? +id : null
);
}
Let’s assume in our app we should make an additional request to get insurance details, based on the appointment endpoint response.
If we want to make a request, we pass a key, otherwise we pass null.
const { data: insurance } = useGetInsurance(data?.hasInsurance ? +id : null);
export const useGetInsurance = (id: number | null) =>
useFetch<InsuranceDetailsInterface>(
id ? pathToUrl(apiRoutes.getInsurance, { id }) : null
);
In our useFetch
abstraction, we set enabled the property in the config as false in case we don’t have a key. In this case, React Query just pauses making requests.
For an appointment with id = 1
, we have hasInsurance = true
. Next, we make another request and show a check icon next to the name. This means that we received an allCovered
flag from the getInsurance
endpoint.
For an appointment with id = 2
we have hasInsurance = false
, and we don’t make requests for the insurance details.
Simple Mutation With Data Invalidation
To create/update/delete data in React Query we use mutations. It means we send a request to the server, receive a response, and based on a defined updater function we mutate our state and keep it fresh without making an additional request.
We have a genetic abstraction for these actions.
const useGenericMutation = <T, S>(
func: (data: S) => Promise<AxiosResponse<S>>,
url: string,
params?: object,
updater?: ((oldData: T, newData: S) => T) | undefined
) => {
const queryClient = useQueryClient();
return useMutation<AxiosResponse, AxiosError, S>(func, {
onMutate: async (data) => {
await queryClient.cancelQueries([url!, params]);
const previousData = queryClient.getQueryData([url!, params]);
queryClient.setQueryData<T>([url!, params], (oldData) => {
return updater ? (oldData!, data) : data;
});
return previousData;
},
// If the mutation fails, use the context returned from onMutate to roll back
onError: (err, _, context) => {
queryClient.setQueryData([url!, params], context);
},
onSettled: () => {
queryClient.invalidateQueries([url!, params]);
},
});
};
Let’s have a look in more detail. We have several callback
methods:
onMutate
(if the request is successful):
- Cancel any ongoing requests.
- Save the current data into a variable.
- If defined, we use an
updater
function to mutate our state by some specific logic, if not, just override the state with the new data. In most cases, it makes sense to define theupdater
function. - Return the previous data.
onError
(if the request is failed):
- Roll back the previous data.
onSettled
(if the request is either successful or failed):
- Invalidate the query to keep the fresh state.
This abstraction we will use for all mutation actions.
export const useDelete = <T>(
url: string,
params?: object,
updater?: (oldData: T, id: string | number) => T
) => {
return useGenericMutation<T, string | number>(
(id) => api.delete(`${url}/${id}`),
url,
params,
updater
);
};
export const usePost = <T, S>(
url: string,
params?: object,
updater?: (oldData: T, newData: S) => T
) => {
return useGenericMutation<T, S>(
(data) => api.post<S>(url, data),
url,
params,
updater
);
};
export const useUpdate = <T, S>(
url: string,
params?: object,
updater?: (oldData: T, newData: S) => T
) => {
return useGenericMutation<T, S>(
(data) => api.patch<S>(url, data),
url,
params,
updater
);
};
That’s why it’s very important to have the same set of [url!, params]
(which we use as a key) in all hooks. Without that the library will not be able to invalidate the state and match the queries.
Let’s see how it works in our app: we have a History
section, clicking by Save
button we send a PATCH
request and receive the whole updated appointment object.
First, we define a mutation. For now, we are not going to perform any complex logic, just returning the new state, that’s why we are not specifying the updater
function.
const mutation = usePatchAppointment(+id);
export const usePatchAppointment = (id: number) =>
useUpdate<AppointmentInterface, AppointmentInterface>(
pathToUrl(apiRoutes.appointment, { id })
);
Note: It uses our generic useUpdate
hook.
Finally, we call the mutate
method with the data we want to patch: mutation.mutate([data!]);
.
Note: In this component, we use an isFetching
flag to indicate updating data on window focus (check Background
fetching section), so, we show the loading state each time when the request is in-flight. That’s why when we click Save
, mutate the state and fetch the actual response we show the loading state as well. Ideally, it shouldn’t be shown in this case, but I haven’t found a way to indicate a background fetching, but don’t indicate fetching when loading the fresh data.
const History = ({ id }: Props) => {
const { data, isFetching } = useGetAppointment(+id);
const mutation = usePatchAppointment(+id);
if (isFetching) {
return (
<Box>
<Box pt={2}>
<Skeleton animation="wave" variant="rectangular" height={15} />
</Box>
<Box pt={2}>
<Skeleton animation="wave" variant="rectangular" height={15} />
</Box>
<Box pt={2}>
<Skeleton animation="wave" variant="rectangular" height={15} />
</Box>
</Box>
);
}
const onSubmit = () => {
mutation.mutate(data!);
};
return (
<>
{data?.history.map((item) => (
<Typography variant="body1" key={item.date}>
Date: {item.date} <br />
Comment: {item.comment}
</Typography>
))}
{!data?.history.length && !isFetching && (
<Box mt={2}>
<span>Nothing found</span>
</Box>
)}
<Box mt={3}>
<Button
variant="outlined"
color="primary"
size="large"
onClick={onSubmit}
disabled={!data || mutation.isLoading}
>
Save
</Button>
</Box>
</>
);
};
Mutation With Optimistic Changes
Now let’s have a look at the more complex example: in our app, we want to have a list, where we should be able to add and remove items. Also, we want to make the user experience as smooth as we can. We are going to implement optimistic changes for creating/deleting jobs.
Here are the actions:
- User inputs the job name and clicks
Add
button. - We immediately add this item to our list and show the loader on the
Add
button. - In parallel we send a request to the API.
- When the response is received we hide the loader, and if it succeeds we just keep the previous entry, update its id in the list, and clear the input field.
- If the response is failed we show the error notification, remove this item from the list, and keep the input field with the old value.
- In both cases we send
GET
request to the API to make sure we have the actual state.
All our logic is:
const { data, isLoading } = useGetJobs();
const mutationAdd = useAddJob((oldData, newData) => [...oldData, newData]);
const mutationDelete = useDeleteJob((oldData, id) =>
oldData.filter((item) => item.id !== id)
);
const onAdd = async () => {
try {
await mutationAdd.mutateAsync({
name: jobName,
appointmentId,
});
setJobName('');
} catch (e) {
pushNotification(`Cannot add the job: ${jobName}`);
}
};
const onDelete = async (id: number) => {
try {
await mutationDelete.mutateAsync(id);
} catch (e) {
pushNotification(`Cannot delete the job`);
}
};
In this example we define our own updater
functions to mutate the state by custom logic: for us, it’s just creating an array with the new item and filtering by id
if we want to delete the item. But the logic could be any, it depends on your tasks.
React Query takes care of changing states, making requests, and rolling back the previous state if something goes wrong.
In the console you could see which requests axios makes to our mock API. We could immediately see the updated list in the UI, then we call POST
and finally we call GET
. It works because we defined onSettled
callback in useGenericMutation
hook, so after success or error we always refetch the data:
onSettled: () => {
queryClient.invalidateQueries([url!, params]);
},
Note: When I highlight the lines in the dev tools you could see a lot of made requests. This is because we change the window focus when we click on the Dev Tools window, and React Query invalidates the state.
If the backend returned the error, we would rollback the optimistic changes, and show the notification. It works because we defined onError
callback in useGenericMutation
hook, so we set previous data if an error happened:
onError: (err, _, context) => {
queryClient.setQueryData([url!, params], context);
},
Prefetching
Prefetching could be useful if we want to have the data in advance and if there is a high possibility that a user will request this data in the near future.
In our example, we will prefetch the car details if the user moves the mouse cursor in the Additional
section area.
When the user clicks the Show
button we will render the data immediately, without calling the API (despite having a 1-second delay).
const prefetchCarDetails = usePrefetchCarDetails(+id);
onMouseEnter={() => {
if (!prefetched.current) {
prefetchCarDetails();
prefetched.current = true;
}
}}
export const usePrefetchCarDetails = (id: number | null) =>
usePrefetch<InsuranceDetailsInterface>(
id ? pathToUrl(apiRoutes.getCarDetail, { id }) : null
);
We have our abstraction hook for the prefetching:
export const usePrefetch = <T>(url: string | null, params?: object) => {
const queryClient = useQueryClient();
return () => {
if (!url) {
return;
}
queryClient.prefetchQuery<T, Error, T, QueryKeyT>(
[url!, params],
({ queryKey }) => fetcher({ queryKey })
);
};
};
To render the car details we use CarDetails
component, where we define a hook to retrieve data.
const CarDetails = ({ id }: Props) => {
const { data, isLoading } = useGetCarDetail(id);
if (isLoading) {
return <CircularProgress />;
}
if (!data) {
return <span>Nothing found</span>;
}
return (
<Box>
<Box mt={2}>
<Typography>Model: {data.model}</Typography>
</Box>
<Box mt={2}>
<Typography>Number: {data.number}</Typography>
</Box>
</Box>
);
};
export const useGetCarDetail = (id: number | null) =>
useFetch<CarDetailInterface>(
pathToUrl(apiRoutes.getCarDetail, { id }),
undefined,
{ staleTime: 2000 }
);
Good point that we don’t have to pass additional props to this component, so in the Appointment
component we prefetch the data and in the CarDetails
component we use useGetCarDetail
hook to retrieve the prefetched data.
By setting extended staleTime
, we allow users to spend a bit more time before they click on the Show
button. Without this setting, the request could be called twice if it takes too long between moving the cursor on the prefetching area and clicking the button.
Suspense
Suspense is an experimental React feature that makes it possible to wait for some code in a declarative way. In other words, we could call the Suspense component and define the fallback
component, which we want to show while we are waiting for the data. We don’t even need the isLoading
flag from React Query. For more information please refer to the official documentation.
Let’s say we have a Service
list, and we want to show the error, and Try again
button if something went wrong.
To get the new developer experience let’s use Suspense, React Query and Error Boundaries together. For the last one, we will use react-error-boundary
Library.
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
fallbackRender={({ error, resetErrorBoundary }) => (
<Box width="100%" mt={2}>
<Alert severity="error">
<AlertTitle>
<strong>Error!</strong>
</AlertTitle>
{error.message}
</Alert>
<Box mt={2}>
<Button
variant="contained"
color="error"
onClick={() => resetErrorBoundary()}
>
Try again
</Button>
</Box>
</Box>
)}
onReset={reset}
>
<React.Suspense
fallback={
<Box width="100%">
<Box mb={1}>
<Skeleton variant="text" animation="wave" />
</Box>
<Box mb={1}>
<Skeleton variant="text" animation="wave" />
</Box>
<Box mb={1}>
<Skeleton variant="text" animation="wave" />
</Box>
</Box>
}
>
<ServicesCheck checked={checked} onChange={onChange} />
</React.Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
Within the Suspense component, we render our ServiceCheck
component, where we call the API endpoint for the service list.
const { data } = useGetServices();
In the hook, we set suspense: true
and retry: 0
.
export const useGetServices = () =>
useFetch<ServiceInterface[]>(apiRoutes.getServices, undefined, {
suspense: true,
retry: 0,
});
On the mock server, we send a response of either 200
or 500
status codes randomly.
mock.onGet(apiRoutes.getServices).reply((config) => {
if (!getUser(config)) {
return [403];
}
const failed = !!Math.round(Math.random());
if (failed) {
return [500];
}
return [200, services];
});
So, if we receive some error from the API, and we don’t handle it, we show the red notification with the message from the response. Clicking on the Try again
button we call resetErrorBoundary()
method, which tries to call the request again. In React Suspense fallback, we have our loading skeleton component, which renders when we are making the requests.
As we could see, this is a convenient and easy way to handle async data, but keep in mind that this is unstable, and probably shouldn’t be used in production right now.
Testing
Testing applications using React Query is almost the same as testing a regular application. We will use React Testing Library and Jest.
First, we create an abstraction for the rendering components.
export const renderComponent = (children: React.ReactElement, history: any) => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const options = render(
<Router history={history}>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</Router>
);
return {
...options,
debug: (
el?: HTMLElement,
maxLength = 300000,
opt?: prettyFormat.OptionsReceived
) => options.debug(el, maxLength, opt),
};
};
We set retry: false
as a default setting in QueryClient
and wrap a component with QueryClientProvider
.
Now, let’s test our Appointment
component.
We start with the easiest one: just checking that the component renders correctly.
test('should render the main page', async () => {
const mocked = mockAxiosGetRequests({
'/api/appointment/1': {
id: 1,
name: 'Hector Mckeown',
appointment_date: '2021-08-25T17:52:48.132Z',
services: [1, 2],
address: 'London',
vehicle: 'FR14ERF',
comment: 'Car does not work correctly',
history: [],
hasInsurance: true,
},
'/api/job': [],
'/api/getServices': [
{
id: 1,
name: 'Replace a cambelt',
},
{
id: 2,
name: 'Replace oil and filter',
},
{
id: 3,
name: 'Replace front brake pads and discs',
},
{
id: 4,
name: 'Replace rare brake pads and discs',
},
],
'/api/getInsurance/1': {
allCovered: true,
},
});
const history = createMemoryHistory();
const { getByText, queryByTestId } = renderComponent(
<Appointment />,
history
);
expect(queryByTestId('appointment-skeleton')).toBeInTheDocument();
await waitFor(() => {
expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
});
expect(getByText('Hector Mckeown')).toBeInTheDocument();
expect(getByText('Replace a cambelt')).toBeInTheDocument();
expect(getByText('Replace oil and filter')).toBeInTheDocument();
expect(getByText('Replace front brake pads and discs')).toBeInTheDocument();
expect(queryByTestId('DoneAllIcon')).toBeInTheDocument();
expect(
mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
).toBeTruthy();
});
We have prepared helpers to mock Axios requests. In the tests, we could specify URL and mock data.
const getMockedData = (
originalUrl: string,
mockData: { [url: string]: any },
type: string
) => {
const foundUrl = Object.keys(mockData).find((url) =>
originalUrl.match(new RegExp(`${url}$`))
);
if (!foundUrl) {
return Promise.reject(
new Error(`Called unmocked api ${type} ${originalUrl}`)
);
}
if (mockData[foundUrl] instanceof Error) {
return Promise.reject(mockData[foundUrl]);
}
return Promise.resolve({ data: mockData[foundUrl] });
};
export const mockAxiosGetRequests = <T extends any>(mockData: {
}): MockedFunction<AxiosInstance> => {
// @ts-ignore
return axios.get.mockImplementation((originalUrl) =>
getMockedData(originalUrl, mockData, 'GET')
);
};
Then, we check there is a loading state and next, wait for the unmounting of the loading component.
expect(queryByTestId('appointment-skeleton')).toBeInTheDocument();
await waitFor(() => {
expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
});
Next, we check that there are necessary texts in the rendered component, and finally check that the API request for the insurance details has been called.
expect(
mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
).toBeTruthy();
It checks that loading flags, fetching data and calling endpoints work correctly.
In the next text, we check that we do not call request for the insurance details if we don’t need it (remember in the component we have a condition, that if in the response from appointment endpoint there is a flag hasInsurance: true
we should call the insurance endpoint, otherwise we shouldn’t).
test('should not call and render Insurance flag', async () => {
const mocked = mockAxiosGetRequests({
'/api/appointment/1': {
id: 1,
name: 'Hector Mckeown',
appointment_date: '2021-08-25T17:52:48.132Z',
services: [1, 2],
address: 'London',
vehicle: 'FR14ERF',
comment: 'Car does not work correctly',
history: [],
hasInsurance: false,
},
'/api/getServices': [],
'/api/job': [],
});
const history = createMemoryHistory();
const { queryByTestId } = renderComponent(<Appointment />, history);
await waitFor(() => {
expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
});
expect(queryByTestId('DoneAllIcon')).not.toBeInTheDocument();
expect(
mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
).toBeFalsy();
});
This test checks that if we have hasInsurance: false
in the response, we will not call the insurance endpoint and render the icon.
Last, we are going to test mutations in our Jobs
component. The whole test case:
test('should be able to add and remove elements', async () => {
const mockedPost = mockAxiosPostRequests({
'/api/job': {
name: 'First item',
appointmentId: 1,
},
});
const mockedDelete = mockAxiosDeleteRequests({
'/api/job/1': {},
});
const history = createMemoryHistory();
const { queryByTestId, queryByText } = renderComponent(
<Jobs appointmentId={1} />,
history
);
await waitFor(() => {
expect(queryByTestId('loading-skeleton')).not.toBeInTheDocument();
});
await changeTextFieldByTestId('input', 'First item');
await clickByTestId('add');
mockAxiosGetRequests({
'/api/job': [
{
id: 1,
name: 'First item',
appointmentId: 1,
},
],
});
await waitFor(() => {
expect(queryByText('First item')).toBeInTheDocument();
});
expect(
mockedPost.mock.calls.some((item) => item[0] === '/api/job')
).toBeTruthy();
await clickByTestId('delete-1');
mockAxiosGetRequests({
'/api/job': [],
});
await waitFor(() => {
expect(queryByText('First item')).not.toBeInTheDocument();
});
expect(
mockedDelete.mock.calls.some((item) => item[0] === '/api/job/1')
).toBeTruthy();
});
Let’s see what is happening here.
- We mock requests for
POST
andDELETE
. - Input some text in the field and press the button.
- Mock
GET
endpoint again, because we assume thatPOST
request has been made, and the real server should send us the updated data; in our case, it’s a list with 1 item. - Wait for the updated text in the rendered component.
- Check that the
POST
request toapi/job
has been called. - Click the
Delete
button. - Mock
GET
endpoint again with an empty list (like in the previous case we assume the server sent us the updated data after deleting). - Check that deleted item doesn’t exist in the document.
- Check that the
DELETE
request toapi/job/1
has been called.
Important Note: We need to clear all mocks after each test to avoid mixing them up.
afterEach(() => {
jest.clearAllMocks();
});
Conclusion
With the help of this real-life application, we went through all of the most common React Query features: how to fetch data, manage states, share between components, make it easier to implement optimistic changes and infinite lists, and learned how to make the app stable with tests.
I hope I could interest you in trying out this new approach in your current or upcoming projects.
Resources
- Example app used in the article
- Official documentation
Axios-mock-adapter