Introduction
React Query is a powerful tool that simplifies the data fetching, caching and synchronization with the server by providing a declarative and composable API of hooks. It was created by Tanner Linsley in 2020 and has gained a lot of popularity since then.
It uses a cache-first approach to optimize the user experience by reducing the number of network requests made by the application. React Query also provides built-in support for features like pagination, optimistic updates, and retrying failed requests.
While building neeto we started using React Query and we learned a few things about how to effectively use it. Below are some of the lessons we learned.
Major advantages of using React Query
-
Simplifies data fetching - It erases a lot of boilerplate and makes our code easier to read.
Here is a comparison of code structure with and without using react-query to fetch data from the server:
Without React Query:
1const [isLoading, setIsLoading] = useState(false); 2const [data, setData] = useState([]); 3const [error, setError] = useState(null); 4 5useEffect(() => { 6 setIsLoading(true); 7 (async () => { 8 try { 9 const data = await fetchPosts(); 10 setData(data); 11 } catch (error) { 12 setError(error); 13 } finally { 14 setIsLoading(false); 15 } 16 })(); 17}, []);
With React Query:
1const { isInitialLoading, data, error } = useQuery(["posts"], fetchPosts);
React Query provides a useQuery hook to fetch data from the server. The entire useEffect was replaced with a single line with React Query. The isInitialLoading, error and the data state is handled out of the box.
-
Provides tools to improve user experience and prevents unnecessary API calls.
React Query provides a powerful caching mechanism that can help prevent unnecessary API calls by storing the results of API requests in a cache. When a component requests data from the API using React Query, it first checks if the data is already present in the cache. If the data is present and hasn't expired, React Query returns the cached data instead of making a new API call.
Here's an example:
Suppose we have a component that displays a list of todos fetched from an API endpoint at https://api.example.com/todos. We can use React Query to fetch the data and store it in the cache like this:
1import { useQuery } from "@tanstack/react-query"; 2 3const TodoList = () => { 4 const { data, isInitialLoading, isError } = useQuery( 5 ["todos"], 6 () => axios.get("https://api.example.com/todos"), 7 { 8 staleTime: 10000, // data considered "fresh" for 10 seconds 9 } 10 ); 11 12 if (isInitialLoading) { 13 return <div>Loading...</div>; 14 } 15 16 if (isError) { 17 return <div>Error fetching data</div>; 18 } 19 20 return ( 21 <ul> 22 {data.map(todo => ( 23 <li key={todo.id}>{todo.title}</li> 24 ))} 25 </ul> 26 ); 27}; 28export default TodoList;
In this example, useQuery is used to fetch the data from the API endpoint. The first argument to useQuery is a unique key that identifies the query, and the second argument is a function that returns the data by making a request to the API endpoint.
Now, suppose the user navigates away from the TodoList component and then comes back to it, if the data in the cache hasn't expired, React Query will return the cached data instead of making a new API call, which can help prevent unnecessary network requests.
If the cache time has expired, React Query will not show the loading state because it will automatically trigger a new API call to fetch the data. During this time, React Query will return the stale data from the cache while it waits for the new data to arrive. This means that our component will continue to display the old data until the new data arrives, but it won't show a loading state because it's not waiting for the data to load.
Once the new data arrives, React Query will update the cache with the new data and then return the updated data to the component.
-
Request Retries - Ability to retry a request in case of errors.
React Query provides built-in support for request retries when API requests fail. This can be useful in situations where network connectivity is unreliable, or when the server is under heavy load and returns intermittent errors.
When we use useQuery or useMutation from React Query, we can provide an optional retry option that specifies the number of times to retry a failed request. Here's an example:
1const { data, isInitialLoading, isError } = useQuery( 2 ["todos"], 3 () => axios.get("https://api.example.com/todos"), 4 { 5 retry: 4, // retry up to 4 times 6 } 7);
In this example, we're using useQuery to fetch data from an API endpoint. We've set the retry option to 4, which means that React Query will retry the API request up to 4 times if it fails. If the API request still fails after 4 retries, React Query will return an error to our component.
We can also customize the behavior of request retries by providing a function to the retryDelay option. This function should take the current retry count as an argument and return the number of milliseconds to wait before retrying the request. Here's an example:
1const { data, isInitialLoading, isError } = useQuery( 2 ["todos"], 3 () => axios.get("https://api.example.com/todos").then(res => res.json()), 4 { 5 retry: 3, // retry up to 3 times 6 retryDelay: retryCount => Math.min(retryCount * 1000, 30000), // wait 30 seconds between retries 7 } 8);
-
Window Focus Refetching - Refetching based on application tab activity.
Window Focus Refetching is a feature of React Query that allows us to automatically refetch our data when the user returns to our application's tab in their browser. This can be useful if we have data that changes frequently or if we want to ensure that our data is always up to date.
React Query will automatically refetch our data when the user returns to our application's tab in their browser. Please note that refetchOnWindowFocus is true by default.
To disable it, we can do it per query or globally in the query client.
Disabling per-query:
1useQuery(["todos"], fetchTodos, { refetchOnWindowFocus: false });
Disabling globally:
1const queryClient = new QueryClient({ 2 defaultOptions: { 3 queries: { 4 refetchOnWindowFocus: false, // default: true 5 }, 6 }, 7});
How to integrate React Query
-
We can install React Query using npm or yarn. Open the terminal and navigate to the project directory. Then, run one of the following commands:
Using npm:
1 npm install @tanstack/react-query
Using yarn:
1 yarn add @tanstack/react-query
-
To integrate react query, first we need to wrap it a QueryClientProvider. In the App.jsx file (or whichever is the topmost parent),
1import queryClient from "utils/queryClient"; 2import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; 3import { QueryClientProvider } from "@tanstack/react-query/reactjs"; 4 5const App = () => ( 6 <QueryClientProvider client={queryClient}> 7 <Main /> 8 <ReactQueryDevtools initialIsOpen={false} /> 9 </QueryClientProvider> 10); 11export default App;
ReactQueryDevtools is a great tool for debugging and optimizing our React Query cache. With ReactQueryDevtools, we can view the current state of our queries, including the query status, data, and any errors that may have occurred. We can also view the cache entries and manually trigger queries or invalidate cache entries.
1import { QueryClient } from "@tanstack/react-query"; 2 3const queryClient = new QueryClient({ 4 defaultOptions: { 5 queries: { 6 staleTime: 100_000, 7 }, 8 }, 9}); 10 11export default queryClient;
The queryClient holds the query cache. The query client provider is a React context provider, which allows us to access the client (and thus the cache) without passing it as a prop explicitly. Every time we call useQueryClient() , we get access to this client. The useQuery & useMutation hooks use the query client internally.
staleTime is the duration until a query transitions from fresh to stale. As long as the query is fresh, data will always be read from the cache only. cacheTime: The duration until inactive queries will be removed from the cache. This defaults to 5 minutes.
Most of the time, if we want to change one of these settings, it's the staleTime that needs adjusting. We have rarely ever needed to tamper with the cacheTime.
-
React query provides us mainly two hooks to fetch and mutate data: useQuery and useMutation.
useQuery:
1const { data, isInitialLoading, isError } = useQuery(["todos"], () => 2 axios.get("https://api.example.com/todos") 3);
In this example, we're using useQuery to fetch data from an API endpoint at https://api.example.com/todos. The first argument to useQuery is a unique key that identifies this query. The second argument is a function that returns a promise that resolves to the data we want to fetch.
The useQuery hook returns an object with three properties: data, isInitialLoading, and isError. In addition to these, useQuery also returns various other properties like isSuccess, isFetching etc and callbacks like onError,onSettled,onSuccess etc. The data property contains the data that was fetched, while isInitialLoading and isError are booleans that indicate whether the data is currently being fetched or if an error occurred while fetching it.
useMutation:
1import { useQueryClient, useMutation } from "@tanstack/react-query"; 2 3const queryClient = useQueryClient(); 4 5const { mutate, isLoading } = useMutation( 6 data => 7 axios.post("https://api.example.com/todos", { 8 body: JSON.stringify(data), 9 }), 10 { 11 onSuccess: () => { 12 queryClient.invalidateQueries("todos"); 13 }, 14 } 15);
In this example, we're using useMutation to send data to an API endpoint at https://api.example.com/todos using a POST request. The first argument to useMutation is a function that takes the data we want to send and returns a promise that resolves to the response from the API.
The second argument to useMutation is an options object that allows us to specify a callback onSuccess function to be called when the mutation succeeds. In addition to onSuccess, useMutation provides us various other callbacks such as onError, onMutate,onSettled etc. In this case, we're using the onSuccess option to invalidate the todos query in the queryClient. This will cause the query to be refetched the next time it's requested, so the updated data will be displayed.
The standards we follow at BigBinary
-
The QueryClientProvider is wrapped in the App.jsx file.
-
The queryClient is placed in utils/queryClient.js file and it can set the default values for stale time, cache time etc.
-
We store the query keys in constants/query.js file.
1export const DEFAULT_STALE_TIME = 3_600_000; 2 3export const QUERY_KEYS = { 4 WEB_PUSH_NOTIFICATIONS: "web-push-notifications", 5 WIDGET_SETTINGS: "widget-settings", 6 WIDGET_INSTALLATION_SCRIPT: "widget-installation-script", 7};
-
To fetch and mutate data, we can create API hooks in hooks/reactQuery folder and based on the structure of the app, we create folders or files inside this folder. For example, we can have a hooks/reactQuery/settings folder to separate settings-related hooks from other API hooks.
-
React query hook files are named in the format use*Api where * is the specific set of apis we are trying to use.
For example inside hooks/reactQuery/useIntegrationsApi.js:
1import { prop } from "ramda"; 2import { useMutation, useQuery } from "react-query"; 3import { DEFAULT_STALE_TIME, QUERY_KEYS } from "src/constants/query"; 4 5import integrationsApi from "apis/integrations"; 6import queryClient from "utils/queryClient"; 7 8const { THIRD_PARTY_APPS } = QUERY_KEYS; 9 10const onMutation = () => queryClient.invalidateQueries(THIRD_PARTY_APPS); 11 12export const useUpdateIntegration = () => 13 useMutation(({ id, options }) => integrationsApi.update(id, options), { 14 onSuccess: onMutation, 15 }); 16 17export const useFetchIntegrations = () => 18 useQuery(THIRD_PARTY_APPS, integrationsApi.fetchThirdPartyApps, { 19 staleTime: DEFAULT_STALE_TIME, 20 select: prop("thirdPartyApps"), 21 });
-
We import the api hooks from the use*Api hook we have created and use it in the file we would like to fetch or mutate queries.
For example:
1import { 2 useFetchIntegrations, 3 useUpdateIntegration, 4} from "hooks/reactQuery/useIntegrationsApi"; 5 6const Integrations = () => { 7 const { data: integrationApps, isLoading } = useFetchIntegrations(); 8 const { mutate: updateIntegration, isLoading: isInstalling } = 9 useUpdateIntegration(); 10 11 return ( 12 <div> 13 <DataTable data={data} /> 14 </div> 15 ); 16}; 17export default Integrations;
At BigBinary we are using React Query v3. Since the update from v3 to v4, there has been some breaking changes. For more information, refer: Documentation