Why Reach for React Query?
Handling asynchronous data in React typically means juggling useState and useEffect hooks, manually fetching from an API, updating state, and catching errors. React Query removes much of that boilerplate by providing built-in caching, background refetching, request deduplication, pagination helpers, prefetching, and direct support for optimistic updates via mutations.
To illustrate these capabilities, a TypeScript application was built on Create React App, using React Query, Axios with a mock adapter, and Material UI. The example domain is a car service system supporting login, an infinite list of appointments, appointment details with a change history, prefetching, and editing required jobs.
Setup and Abstractions
The app wraps the component tree in QueryClientProvider, with a configured QueryClient for global defaults. Instead of inventing separate string identifiers for each query, the abstraction uses the API request URL plus parameters as the unique key. This reliance on a compound key ([url, params]) is critical: mutations that update the same resource must share this identical key structure, or React Query will fail to match and invalidate queries correctly.
The fetcher is an Axios wrapper that pulls the URL and params straight from the queryKey argument. One config detail is enabled: !!url, which pauses any request when no key is present, a useful pattern for conditional queries discussed later. During development, React Query Devtools can be placed in the root component for easier state inspection.
A Centralized Path to Authentication
Authentication here just requires any email/password combination; the mock server stores the returned token in cookies. React Query proves useful for sharing state without prop-drilling: a root App component handles redirect logic based on the profile request, while a separate header component shows the logged-in user's name. Both subscribe to the same hook, but the underlying API is hit only once.
The hook for fetching the profile sets retry: false. If this request fails, the assumption is the user is unauthorized, so the root component redirects to the login page. On successful login, all queries are invalidated to refresh app-wide data—this can be narrowed to a single query with queryClient.invalidateQueries(apiRoutes.getProfile).
Two Problems Solved: Request Deduping and Data Sharing
Consider two separate components that need the same data from one endpoint—the way a dashboard might display a list of appointments and a summary count. The naive approach fires two network calls. In this example, both components consume the same useGetAppointmentsList() wrapper; adding a log to the network layer confirms the single GET /api/getUserList isn't duplicated. This is useful for the login flow as alongside user profile data used in multiple distinct parts of the UI simultaneously.
These hooks are reusable to the extent the need to cover cases in applications where you have to carry updated state. Fetching data and grouping initial logic into composable wrappers is one key benefit: they collapse significant imperative logic directly into the hook's implementation.
Pagination, Window Refocus, and Conditional Calls
For a paginated list with a "Load more" button, the regular useQuery hook is insufficient. The application relies on the dedicated useInfiniteQuery hook. An abstraction around it exposes the pagination parameters, passing pageParam from getNextPageParam directly into the fetcher. The returned fetchNextPage, hasNextPage, and isFetchingNextPage fields steer the UI button's visible state.
Window focus is treated as an event that can render stale information obvious whenever users return to that tab. By default, this watcher feature can present users with the most current data automatically, particularly useful when multiple authorized users might modify records concurrently.
refetchIntervalrefetchIntervalInBackgroundrefetchOnMountrefetchOnReconnectrefetchOnWindowFocus
In the demo interface, this can disable refetching options globally during any connecting period; setting values such as isFetching to enable some partial backdrop becomes a useful UX primitive to signal out-of-band data syncing.
Conditional requests surface React Query's approach to operations otherwise fronted by if statements. Example: to obtain specifics for appointments, additional checks require returning through a React Query hook, but "rules of hooks" prohibit conditional calls. Calling the hook with a null url whenever hasInsurance is false instructs React Query to pause it without firing, and code logic in the component shows the useful state when data exists. When an insurance field comes back from the API, the new independent request shows the allCovered result.
The Workings of Smart Mutations
Updates, creations, and deletions don't rely on simple fetches — they go through mutations. A reusable tracker that anticipates errors while protecting the cache builds core abstraction:
onMutate: Cancel all related running requests and store current data in a temporary variable. If it includes a function, updates the cache item; otherwise override state with the new payload using state returned from the server.onError: Use the saved payload from mutation to conditionally reposition the optimistic portion of the state.onSettled: Finally, invalidate the particular key pattern to ensure synchronization for all subscribers.
In the "History" section, submitting a "Save" control ships a PATCH request through this operational abstraction and receives the revised appointment object. The UI’s loading indication is bound to isFetching from the focus-fetcher setup — since this flag is shared between mutual network states (window refetch vs local mutation), the "Save" shows covered hints of implementation quirk but documented precisely in this example's code.
The conclusion drawn is that investing in a strong key setup plus generic wrapper functions streamlines backend state so hooks result in simpler, less repetitive parts and removes boilerplate-heavy handlers.
Optimistic Mutations Done Right
Building a smooth user experience often means updating the UI before the server confirms the change. For our jobs list we want instant feedback when adding or removing items, without waiting for a round trip.
The flow we target:
- The user types a job name and hits
Add. - The item appears immediately, with a loader on the button.
- A
POSTrequest fires in the background. - On success we keep the item, update its id, and clear the input.
- On error we roll back, show a notification, and keep the typed value.
Our generic mutation hook handles the whole sequence through updater functions. We define custom logic to insert or remove items from the cached list by id. Whatever your state shape looks like, these updaters can implement any transformation you need.
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`);
}
};
React Query takes care of the state transition, fires the request, and restores the previous list when something fails. In the network tab you can see the order of operations: the UI shows the new item, axios sends the POST, and because we set an onSettled callback in useGenericMutation, a fresh GET request always follows — whether the write succeeded or not.
onSettled: () => {
queryClient.invalidateQueries([url!, params]);
},Note: The many requests you see in the devtools are caused by switching focus to the browser window. React Query invalidates the cache on window refocus by default.
If the backend returns an error, the onError callback in the same hook resets the data to the previous snapshot, and our error banner tells the user what went wrong.
onError: (err, _, context) => {
queryClient.setQueryData([url!, params], context);
},Warming the Cache
Prefetching gives you a performance edge when you can predict that a user will need certain data soon. In our appointment view, moving the mouse over the Additional section triggers a background fetch of the car details.
By the time the user clicks Show, the data is already in the cache. Even with our mock API's one-second artificial delay, the detail view renders instantly without another call.
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
);
The abstraction hook looks like this:
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 })
);
};
};
The CarDetails component that renders the result simply uses the same data hook — you don't pass any extra props from the parent. Prefetching lives entirely in the Appointment component; consumption happens in CarDetails via useGetCarDetail.
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 }
);
An extended staleTime is important here. Without it, if the user hovers over the prefetch area and only clicks the button seconds later, React Query would consider the data stale and fire a second request anyway.
Suspense + Error Boundaries
Suspense lets us describe data fetching in a declarative way: wrap a component, provide a fallback skeleton, and you never look at an isLoading flag again. It's still experimental in React, but combined with React Query and an error boundary it makes asynchronous UI remarkably clean.
For our Service list, we want to display an error message with a Try again button when the request fails. We pair React's Suspense with the 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>
Inside the Suspense boundary, ServiceCheck calls the service list endpoint:
const { data } = useGetServices();
The data hook sets suspense: true and disables retries so an error propagates straight to the boundary:
export const useGetServices = () =>
useFetch<ServiceInterface[]>(apiRoutes.getServices, undefined, {
suspense: true,
retry: 0,
});
The mock server is set to answer with either 200 or 500 randomly. When an error hits, the boundary shows the message and the retry button; clicking it calls resetErrorBoundary() and re-runs the query. During that request, the Suspense fallback skeleton renders.
mock.onGet(apiRoutes.getServices).reply((config) => {
if (!getUser(config)) {
return [403];
}
const failed = !!Math.round(Math.random());
if (failed) {
return [500];
}
return [200, services];
});It's an elegant and compact pattern — just remember that Suspense is not production-stable yet.
Testing the Data Flow
React Query apps test much like any other React app. We use React Testing Library with Jest and start with a rendering helper:
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),
};
};
The helper creates a QueryClient configured with retry: false and wraps the component in a QueryClientProvider with that client.
Our first Appointment test is the simplest: does the component render at all? We have mock adapters for axios that let us pin down a URL and a mock response.
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();
});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')
);
};
From there we assert that a loading state shows up first, then disappears after the mocked data lands.
expect(queryByTestId('appointment-skeleton')).toBeInTheDocument();
await waitFor(() => {
expect(queryByTestId('appointment-skeleton')).not.toBeInTheDocument();
});
Next, verify the expected text is present in the rendered output, and finally that the API request for insurance details was actually made. This covers loading flags, fetch execution and endpoint integration.
expect(
mocked.mock.calls.some((item) => item[0] === '/api/getInsurance/1')
).toBeTruthy();
A second test makes sure that the insurance endpoint is not called when the appointment response contains hasInsurance: false. The component only renders the icon and no request goes out.
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();
});
Mutations get their own round of tests with the Jobs component:
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();
});
Breaking that down:
- We mock
POSTandDELETEresponses. - Enter a value and trigger the add action.
- Mock the following
GETas if the server now returns a list with that item. - Wait for the new text to appear.
- Assert the
POSTwent toapi/job. - Delete the item.
- Mock the next
GETwith an empty array, simulating the server state after deletion. - Check the deleted item is gone.
- Assert the
DELETEwas sent toapi/job/1.
Clearing all mocks at the end of each test is critical so responses do not leak across tests.
afterEach(() => {
jest.clearAllMocks();
});
Where This Leaves Us
What you have seen covers the daily essentials: fetching, cache state, sharing data between components, optimistic writes, prefetching and Suspense mode. The same sample app also walks through infinite lists and a testing setup that keeps these patterns honest.
- Reference code for the full example is on GitHub.
- The official React Query docs explain every API in more depth.
- The
axios-mock-adapterpackage is what powers the request stubs.




