Streamlining React: Utilizing React Query for Scalability
Cristafovici Dan
Posted on May 6, 2024
In the previous part, we set up all the necessary Axios configurations to make it easy to scale and maintain. In today’s part, we’ll install React Query and perform a similar setup to configure global and local variables.
React Query Configuration
First, let’s define our config file. In our API folder, we can create a file called queryClient.ts and define the configuration as follows. This is a basic configuration to start with, but we’ll add new properties later in the article.
import { QueryClient } from '@tanstack/react-query';
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
}
}
});
The next step is to wrap our application with the React Query Provider as follows. The QueryClientProvider expects our config file as an argument. Additionally, we included the ReactQueryDevtools from the devtools extension.
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { queryClient } from "./api/queryClient";
import { QueryClientProvider } from "@tanstack/react-query";
<QueryClientProvider client={queryClient}>
<ReactQueryDevtools initialIsOpen={false} />
<Provider store={store}>
<RouterProvider
router={applicationBrowserRouter}
fallbackElement={<Spin size="large" className="spinner" />}
/>
</Provider>
</QueryClientProvider>
Query
The basic React Query configuration is now ready for use. The main principle of React Query, as shown in various sources such as tutorials and documentation, is to call the useQuery function, providing queryKey and queryFn. Typically, the documentation suggests providing the axios call directly in queryFn, which makes sense. However, as the project becomes more complex, it becomes challenging to maintain all the logic in a single place. To make our application easier to read and debug, the logic for fetching will be moved into a separate file. To understand the benefits of this approach, let’s first examine a similar call before refactoring to useQuery.
In this example, the fetchListOfProduct function is invoked when the component mounts for the first time and whenever specific properties change. The isLoading status is toggled to provide visual feedback to the user while the request is being processed. Inside the fetchProducts function, we await the completion of the request, perform necessary data transformations such as grouping by categories, and update the local state accordingly. Finally, based on the outcome of the request, we display relevant notifications or handle errors appropriately.
const fetchListOfProduct = () => {
setIsLoading(true);
const fetchProducts = async () => {
const {
data,
counts: {
foodCount = 0,
supplementsCount = 0,
vitaminsCount = 0,
filterCount: filterCountResponse = 0,
},
} = await ProductService.getAllProducts({
productCategoryId,
offset,
limit: PRODUCTS_QUERY_LIMIT,
filters,
});
setCounts({
[ProductCategoryId.FOOD]: foodCount,
[ProductCategoryId.SUPPLEMENTS]: supplementsCount,
[ProductCategoryId.VITAMINS]: vitaminsCount,
});
setFilterCount(filterCountResponse);
setProducts(data);
showNotification(NOTIFICATION_TYPE.SUCCESS);
};
fetchProducts()
.catch((error) => {
showNotification(NOTIFICATION_TYPE.ERROR, error.response.data.message);
})
.finally(() => setIsLoading(false));
};
useEffect(() => {
fetchListOfProduct();
}, [productCategoryId, offset, filters]);
Agree that keeping all this code within our component can be hard to maintain, as the component not only includes simple fetch code but also handler logic functions and more. The main idea of the proposed structure is to introduce several layers between the component that renders and the server that responds with data. The aim is to establish the following chain:
Component -> React Query -> Intermediate data interceptor -> Axios -> Server.
So let’s call the useQuery function in our component. Using a destructuring method, we can already receive isPending (analog of our isLoading) and remove one unnecessary state and handling.
// config.ts
export const MY_PRODUCTS_QUERY_KEY = "MY_PRODUCTS_QUERY_KEY";
const { data, isPending } = useQuery({
queryKey: [MY_PRODUCTS_QUERY_KEY],
queryFn: ...
});
Let’s create Query Classes akin to Axios Classes, acting as interceptors between Axios responses and useQuery. They’ll map data according to project needs
export default class ProductQueryMethods {
public static readonly getAllProducts = async (
query: GetAllProductsQueryResponse,
): Promise<ProductQueryResponse> => {
const {
data,
counts: {
foodCount = 0,
supplementsCount = 0,
vitaminsCount = 0,
filterCount: filterCountResponse = 0,
},
} = await OrderService.getAllProducts(query);
const counts = {
[ProductCategoryId.FOOD]: foodCount,
[ProductCategoryId.SUPPLEMENTS]: supplementsCount,
[ProductCategoryId.VITAMINS]: vitaminsCount,
};
return { orders: data, counts, filterCount };
};
}
And finally, our component looks much cleaner, easier to understand, and free from redundant code.
const { data, isPending } = useQuery({
queryKey: [MY_PRODUCTS_QUERY_KEY],
queryFn: () =>
ProductQueryMethods.getAllProducts({
productStatusId,
offset,
limit: ORDERS_QUERY_LIMIT,
filters,
}),
});
React Query is popular not just for providing the useQuery hook, its caching mechanism is a powerful tool to avoid unnecessary repeat requests to the server.
In our example with useEffect, we kept the data in local state. Every time one of the parameters changed, we made a repeated request to the server, replacing the previous data with new data. With React Query, we can keep data in the cache and retrieve it from there without making a new request.
In our example with products, we send offset and limit parameters to the server, typically for pagination. For instance, to see the first page with 20 results, we send a request with ?limit=20. When we move to the second page, we send ?limit=20&offset=20, and so on.
Returning to the second page sends another request with limit=20&offset=20. But does it make sense to retrieve new data if we already have the previous data in our cache? I think not.
So, how do we “enable” the cache? Actually, the cache is always enabled, the question is how to access it. The main principle of caching is to check the global store to see if there’s any data with the specified key and retrieve it (if not, retrieve it from the backend).
To retrieve the data, we need a unique key. The key is a significant part of the philosophy of React Query. In the previous example, we provided only one argument for queryKey, but you can see that it is an array, and we can extend it by adding three new properties:
const { data, isPending } = useQuery({
queryKey: [MY_PRODUCTS_QUERY_KEY, offset, limit, productStatusId],
queryFn: () =>
ProductQueryMethods.getAllProducts({
productStatusId,
offset,
limit: ORDERS_QUERY_LIMIT,
filters,
}),
});
These keys will be concatenated into a unique key, and when one of the following properties changes, React Query attempts to retrieve the data using this key from cache storage or sends a request to the backend
But to make it work, there is a small prerequisite. In our queryConfiguration file, we need to adjust it with a new property: staleTime, which expects the value to be in milliseconds:
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
...,
staleTime: 30000,
},
},
});
The staleTime property gives query the idea of refreshing the request. I won’t delve too deeply into React Query properties as we have another topic, but in simple terms, it determines the lifetime of the request. In addition to my explanation of the caching mechanism, React Query checks if the request is not “outdated.” In our case, it is set to 30 seconds. If it is outdated, another request will be sent.
Mutation
Apart from fetching data, we also need to send it back, possibly in a modified form. For sending data, we’ll use the mutation hook. I prefer keeping these methods separate and calling them in the component. Mutations are named as hooks, and they aren’t grouped into a separate class.
import { UseMutationResult, useMutation } from "@tanstack/react-query";
export const useUpdateProductById = (): UseMutationResult<
ProductsResponse,
Error,
UpdateProductPayload,
void
> => {
return useMutation({
mutationFn: (data: UpdateProductPayload) => {
const { id, ...rest } = data;
return ProductService.updateProductById(id, rest);
},
});
};
In the component, our custom hook returns the mutate function (named updateProductById). It also provides the isPending argument, indicating the current request state. Additionally, we can pass an onSuccess callback as a second argument, executed upon a successful response, and use onError for error handling.
const { mutate: updateProductById, isPending } = useUpdateProductById();
const editCurrentProduct = (values: UpdateProductPayload) => {
updateProductById(
{ ...values, id },
{
onSuccess: () => {
fetchProducts();
setModalIsOpen(false);
},
onError: () => {}
}
);
};
Setting Global Notifications
You may have noticed in the example with useEffect, which we refactored earlier in case of an error, I used notifications to show the status to the user. The problem was that I needed to import and call the same function every time. Let’s optimize this by moving it into the global scope. Returning to the queryClient configuration file, we can add these two new properties: queryCache for queries and mutationCache for mutations. Each of them will receive onSuccess and onError callbacks depending on the status of the request.
export const queryClient = new QueryClient({
defaultOptions: {
....
},
queryCache: new QueryCache({
onSuccess: (_data: unknown, query: Query<unknown, unknown, unknown, QueryKey>): void => {
// ...
},
onError: (error: unknown, query: Query<unknown, unknown, unknown, QueryKey>): void => {
// ...
}
}),
mutationCache: new MutationCache({
onError: (
error: unknown,
_variables: unknown,
_context: unknown,
mutation: Mutation<unknown, unknown, unknown, unknown>
): void => {
/ ...
},
onSuccess: (
_data: unknown,
_variables: unknown,
_context: unknown,
mutation: Mutation<unknown, unknown, unknown, unknown>
): void => {
// ...
}
})
});
These global settings will be invoked every time when the useQuery or useMutation is executed. However, in a real project, we may not need to show the notification for every request. In this case, we can handle different scenarios. Firstly, for our query, we can add a new property called meta:
const { data, isPending } = useQuery({
...,
meta: {
ERROR_SOURCE: "[Products Failed]",
SUCCESS_MESSAGE: "All products have been successfully received",
},
});
And similarly for mutation:
export const useUpdateProductById = (): UseMutationResult<
ProductsResponse,
Error,
UpdateProductPayload,
void
> => {
return useMutation({
...,
meta: {
ERROR_SOURCE: "[Update product failed]",
SUCCESS_MESSAGE: "The product has been successfully updated",
},
});
};
Now we can adjust our global config to look like this:
export const queryClient = new QueryClient({
...,
queryCache: new QueryCache({
onSuccess: (_data: unknown, query: Query<unknown, unknown, unknown, QueryKey>): void => {
if (query.meta?.SUCCESS_MESSAGE) {
toast.success(`${query.meta.SUCCESS_MESSAGE}:`);
}
},
onError: (error: unknown, query: Query<unknown, unknown, unknown, QueryKey>): void => {
if (axios.isAxiosError(error) && query.meta?.ERROR_SOURCE) {
toast.error(`${query.meta.ERROR_SOURCE}: ${error.response?.data?.message}`);
}
if (error instanceof Error && query.meta?.ERROR_SOURCE) {
toast.error(`${query.meta.ERROR_SOURCE}: ${error.message}`);
}
}
}),
mutationCache: new MutationCache({
onError: (
error: unknown,
_variables: unknown,
_context: unknown,
mutation: Mutation<unknown, unknown, unknown, unknown>
): void => {
if (axios.isAxiosError(error) && mutation.meta?.ERROR_SOURCE) {
toast.error(`${mutation.meta.ERROR_SOURCE}: ${error.response?.data?.message}`);
}
if (error instanceof Error && mutation.meta?.ERROR_SOURCE) {
toast.error(`${mutation.meta.ERROR_SOURCE}: ${error.message}`);
}
},
onSuccess: (
_data: unknown,
_variables: unknown,
_context: unknown,
mutation: Mutation<unknown, unknown, unknown, unknown>
): void => {
if (mutation.meta?.SUCCESS_MESSAGE) {
toast.success(`${mutation.meta.SUCCESS_MESSAGE}:`);
}
}
})
});
This example demonstrates how I handled notifications. If the specific meta parameter is provided in the query or mutation, the toast notification will be invoked. This approach avoids code duplication and keeps all the settings in the same part.
These two articles were aimed to share with you the way to handle communication with the server in a more efficient way, and I hope they will inspire you to try new things in your project.
Posted on May 6, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.