Installation npm i @tanstack/react-query Configuration import { QueryClient, QueryClientProvider, } from '@tanstack/react-query' const App = ({ routes, app }) => ( <QueryClientProvider client={new QueryClient()}> <StyledEngineProvider injectFirst> <ThemeProvider theme={theme}> <LocalizationProvider adapterLocale={momentLocale} dateAdapter={AdapterMoment}> <I18nextProvider i18n={i18n}> <Suspense fallback={<ProgressIndicator indicator={IndicatorType.OVERLAY} />}> <AppContent routes={routes} app={app} /> </Suspense> </I18nextProvider> </LocalizationProvider> </ThemeProvider> </StyledEngineProvider> </QueryClientProvider> ) Custom configuration We may provide default settings for whole app. export const queryClient = new QueryClient({ defaultOptions: { queries: { refetchOnWindowFocus: false, refetchOnMount: false, refetchOnReconnect: false, retry: false, staleTime: 1000 * 60 * 60 * 24 } } }) const App = ({ routes, app }) => ( <QueryClientProvider client={queryClient}> ... </QueryClientProvider> ) Query on mount + cache + never re-fetch + fallback data // CurrencyInput.js import DropdownField from '../../../DropdownField' import { useTranslation } from 'react-i18next' import { Validator } from '../../../fields/validation' import { useCurrenciesQuery } from './useCurrenciesQuery' import { useSelector } from 'react-redux' import { useQuery } from '@tanstack/react-query' import { selectIsInvoiceFormDisabled } from '../../../../containers/selectors/invoice-selectors' const defaultCurrencies = ['AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYR', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', 'HTG', 'HUF', 'IDR', 'ILS', 'INR', 'IQD', 'IRR', 'ISK', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR', 'KMF', 'KPW', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LTL', 'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT', 'MOP', 'MRO', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLL', 'SOS', 'SRD', 'SSP', 'STD', 'SYP', 'SZL', 'THB', 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VEF', 'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMK', 'ZWL'] export const CurrencyInput = () => { const { t } = useTranslation() const isInvoiceFormDisabled = useSelector(selectIsInvoiceFormDisabled) const { data: currencies } = useQuery({ queryKey: ['get currencies'], queryFn: () => resource.get(appSettings.currenciesUrl, false), select: (res) => [...new Set(Object.values(res.data))].map(code => code).filter(code => code !== '').sort(), staleTime: Infinity, cacheTime: Infinity }) return ( <DropdownField items={currencies || defaultCurrencies} name='sum.currency' label={t('label.details.currency')} validate={Validator.requiredField} fullWidth disabled={isInvoiceFormDisabled} /> ) } Reusable query // useCurrenciesQuery.js import { useQuery } from '@tanstack/react-query' import { appSettings } from '../../../../utils/app-settings' import resource from '../../../../utils/ResourceUtil' export const useCurrenciesQuery = () => { return useQuery({ queryKey: ['get currencies'], queryFn: () => resource.get(appSettings.currenciesUrl, false), select: (res) => [...new Set(Object.values(res.data))].map(code => code).filter(code => code !== '').sort(), staleTime: Infinity, cacheTime: Infinity }) } // CurrencyInput.js import DropdownField from '../../../DropdownField' import { useTranslation } from 'react-i18next' import { Validator } from '../../../fields/validation' import { useCurrenciesQuery } from './useCurrenciesQuery' import { useSelector } from 'react-redux' import { selectIsInvoiceFormDisabled } from '../../../../containers/selectors/invoice-selectors' const defaultCurrencies = ['AED', 'AFN', 'ALL', 'AMD', 'ANG', 'AOA', 'ARS', 'AUD', 'AWG', 'AZN', 'BAM', 'BBD', 'BDT', 'BGN', 'BHD', 'BIF', 'BMD', 'BND', 'BOB', 'BRL', 'BSD', 'BTN', 'BWP', 'BYR', 'BZD', 'CAD', 'CDF', 'CHF', 'CLP', 'CNY', 'COP', 'CRC', 'CUP', 'CVE', 'CZK', 'DJF', 'DKK', 'DOP', 'DZD', 'EGP', 'ERN', 'ETB', 'EUR', 'FJD', 'FKP', 'GBP', 'GEL', 'GHS', 'GIP', 'GMD', 'GNF', 'GTQ', 'GYD', 'HKD', 'HNL', 'HRK', 'HTG', 'HUF', 'IDR', 'ILS', 'INR', 'IQD', 'IRR', 'ISK', 'JMD', 'JOD', 'JPY', 'KES', 'KGS', 'KHR', 'KMF', 'KPW', 'KRW', 'KWD', 'KYD', 'KZT', 'LAK', 'LBP', 'LKR', 'LRD', 'LSL', 'LTL', 'LYD', 'MAD', 'MDL', 'MGA', 'MKD', 'MMK', 'MNT', 'MOP', 'MRO', 'MUR', 'MVR', 'MWK', 'MXN', 'MYR', 'MZN', 'NAD', 'NGN', 'NIO', 'NOK', 'NPR', 'NZD', 'OMR', 'PAB', 'PEN', 'PGK', 'PHP', 'PKR', 'PLN', 'PYG', 'QAR', 'RON', 'RSD', 'RUB', 'RWF', 'SAR', 'SBD', 'SCR', 'SDG', 'SEK', 'SGD', 'SHP', 'SLL', 'SOS', 'SRD', 'SSP', 'STD', 'SYP', 'SZL', 'THB', 'TJS', 'TMT', 'TND', 'TOP', 'TRY', 'TTD', 'TWD', 'TZS', 'UAH', 'UGX', 'USD', 'UYU', 'UZS', 'VEF', 'VND', 'VUV', 'WST', 'XAF', 'XCD', 'XOF', 'XPF', 'YER', 'ZAR', 'ZMK', 'ZWL'] export const CurrencyInput = () => { const { t } = useTranslation() const isInvoiceFormDisabled = useSelector(selectIsInvoiceFormDisabled) const { data: currencies } = useCurrenciesQuery() return ( <DropdownField items={currencies || defaultCurrencies} name='sum.currency' label={t('label.details.currency')} validate={Validator.requiredField} fullWidth disabled={isInvoiceFormDisabled} /> ) } Fetch on click We need to disable the query and trigger the refetch function. export const useCurrenciesQuery = () => { return useQuery({ queryKey: ['get currencies'], queryFn: () => resource.get(appSettings.currenciesUrl, false), select: (res) => [...new Set(Object.values(res.data))].map(code => code).filter(code => code !== '').sort(), staleTime: Infinity, cacheTime: Infinity, enabled: false }) } export const CurrencyInput = () => { const { data: currencies, isLoading, isFetching, isError, error, refetch } = useCurrenciesQuery() return ( <button onClick={refetch}>Fetch</button> ) } Side effects After successful or failed fetching we usually have to use useEffect to perform some action React-query provides onSuccess & onError callback parameters Parallel queries By default queries are executed in parallel Just add multiple useQuery hooks and dedicated name aliases function App () { // The following queries will execute in parallel const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers }) const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTeams }) const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects }) ... } Sequential (dependent) queries Use the enabled option to tell a query when it is ready to run // Get the user const { data: user } = useQuery({ queryKey: ['user', email], queryFn: getUserByEmail, }) const userId = user?.id // Then get the user's projects const { status, fetchStatus, data: projects, } = useQuery({ queryKey: ['projects', userId], queryFn: getProjectsByUser, // The query will not execute until the userId exists enabled: !!userId, }) Initial data Show init data & fetch actual data immediately // Will show initialTodos immediately, but also immediately refetch todos after mount const result = useQuery({ queryKey: ['todos'], queryFn: () => fetch('/todos'), initialData: initialTodos, }) Show init data & fetch actual data later // Show initialTodos immediately, but won't refetch until another interaction event is encountered after 1000 ms const result = useQuery({ queryKey: ['todos'], queryFn: () => fetch('/todos'), initialData: initialTodos, staleTime: 60 * 1000, // 1 minute // This could be 10 seconds ago or 10 minutes ago initialDataUpdatedAt: initialTodosUpdatedTimestamp, // eg. 1608412420052 }) Initial data from cache We can access previously cached data by its query id via queryClient.getQueryData() method. const result = useQuery({ queryKey: ['todo', todoId], queryFn: () => fetch('/todos'), initialData: () => { // Use a todo from the 'todos' query as the initial data for this todo query return queryClient.getQueryData(['todos'])?.find((d) => d.id === todoId) }, }) Pagination Page information should be in the query key array The UI jumps in and out of the success and loading states because each new page is treated like a brand new query This experience is not optimal Use keepPreviousData: true to persist previous data during loading new one function Todos() { const [page, setPage] = useState(0) const fetchProjects = (page = 0) => fetch('/api/projects?page=' + page).then((res) => res.json()) const { isLoading, isError, error, data, isFetching, isPreviousData } = useQuery({ queryKey: ['projects', page], queryFn: () => fetchProjects(page), keepPreviousData: true }) if (isLoading) return <div>Loading...</div> if (isError) return <div>Error: {error.message}</div> return ( <> <div> {data.projects.map(project => <p key={project.id}>{project.name}</p>)} </div> <span>Current Page: {page + 1}</span> <button onClick={() => setPage(old => Math.max(old - 1, 0))} disabled={page === 0} > Previous Page </button> <button onClick={() => { if (!isPreviousData && data.hasMore) { setPage(old => old + 1) } }} // Disable the Next Page button until we know a next page is available disabled={isPreviousData || !data?.hasMore} > Next Page </button> {isFetching ? <span> Loading...</span> : null}{' '} </> ) } Infinite queries (load more) useInfiniteQuery injects the object with pageParam into the fetcher function getNextPageParam & getPreviousPageParam options are available to determine if there is more data to load hasNextPage boolean is true if getNextPageParam returns a value other than undefined same logic works for getPreviousPageParam get next data with fetchNextPage & fetchPreviousPage functions function Projects() { const fetchProjects = async ({ pageParam = 0 }) => { const res = await fetch('/api/projects?cursor=' + pageParam) return res.json() } const { data, error, fetchNextPage, hasNextPage, isFetching, isFetchingNextPage, status, } = useInfiniteQuery({ queryKey: ['projects'], queryFn: fetchProjects, getNextPageParam: (lastPage, pages) => lastPage.nextCursor, }) if (status === 'loading') return <p>Loading...</p> if (status === 'error') return <p>Error: {error.message}</p> return ( <> {data.pages.map((group, i) => ( <React.Fragment key={i}> {group.projects.map((project) => <p key={project.id}>{project.name}</p>)} </React.Fragment> ))} <div> <button onClick={() => fetchNextPage()} disabled={!hasNextPage || isFetchingNextPage}> {isFetchingNextPage && 'Loading more...'} {hasNextPage ? 'Load More' : 'Nothing more to load'} </button> </div> <div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div> </> ) } Mutation For post/delete/update requests we may use useMutation hook All we pass into mutate function goes into our fetcher function Use mutate method to trigger the request function App() { const {isLoading, isError, isSuccess, error, mutate} = useMutation({ mutationFn: (newTodo) => axios.post('/todos', newTodo) }) return ( <> {isLoading && <div>Loading...</div>} {isError && <div>An error occurred: {error.message}</div>} {isSuccess ? <div>Todo added!</div> : null} <button onClick={() => mutate({ id: new Date(), title: 'Do Laundry' }) } > Create Todo </button> </> ) } Query invalidation (refetching after mutation) After data modification with useMutate hook we may need to manually update the cached data But we may also tell react-query to do that automatically We need to get queryClient instance which has access to our cache And provide desired query key we want to invalidate Refetching of new data will happen automatically Invalidate all queries import { useQuery, useQueryClient } from '@tanstack/react-query' const queryClient = useQueryClient() queryClient.invalidateQueries({ queryKey: ['todos'] }) // Both queries below will be invalidated const todoListQuery = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList, }) const todoListQuery = useQuery({ queryKey: ['todos', { page: 1 }], queryFn: fetchTodoList, }) Invalidate specific query queryClient.invalidateQueries({ queryKey: ['todos', { type: 'done' }], }) // The query below will be invalidated const todoListQuery = useQuery({ queryKey: ['todos', { type: 'done' }], queryFn: fetchTodoList, }) // However, the following query below will NOT be invalidated const todoListQuery = useQuery({ queryKey: ['todos'], queryFn: fetchTodoList, }) Handling mutation response Invalidation is cool, but it requires additional http call Quite often we want manually update data & ui after successful mutation, because we already have all needed information Just use onSuccess callback for that And update the specific query cache with queryClient.setQueryData() Do not mutate the cache, same way we do with state values const queryClient = useQueryClient() const { mutate } = useMutation({ mutationFn: editTodo, onSuccess: (data) => { queryClient.setQueryData(['todo', { id: 5 }], data) } }) mutate({ id: 5, name: 'Do the laundry', }) // The query below will be updated with the response from the // successful mutation const { status, data, error } = useQuery({ queryKey: ['todo', { id: 5 }], queryFn: fetchTodoById, }) setQueryData also accepts a callback to update the data with access to current cache queryClient.setQueryData( ['posts', { id }], (oldData) => oldData ? { ...oldData, title: 'my new post title' } : oldData ) Optimistic Updates Optimistic update is the data update before performing a mutation with assumption nothing goes wrong It is done by onMutate , onError & onSettled callbacks onMutate is called before mutate() function is fired onMutate accepts the same parameters as mutate() function at the beginning we cancel any outgoing refetches with queryClient.cancelQueries() , they should not overwrite our optimistic update then we get hold of the previous cached data with queryClient.getQueryData() in case of an error and we need to roll back then we just update the cache the same way as we did above with queryClient.setQueryData() at the end we return the previous query data in case of an error and a need to rollback (not very clear) onError callback we return just rollback data by accessing previous cached data inside context argument onSettled fires on success or error and we just re-fetch the data with queryClient.invalidateQueries() const queryClient = useQueryClient() useMutation({ mutationFn: updateTodo, // When mutate is called: onMutate: async (newTodo) => { // Cancel any outgoing refetches // (so they don't overwrite our optimistic update) await queryClient.cancelQueries({ queryKey: ['todos'] }) // Snapshot the previous value const previousTodos = queryClient.getQueryData(['todos']) // Optimistically update to the new value queryClient.setQueryData(['todos'], (old) => [...old, newTodo]) // Return a context object with the snapshotted value return { previousTodos } }, // If the mutation fails, // use the context returned from onMutate to roll back onError: (err, newTodo, context) => { queryClient.setQueryData(['todos'], context.previousTodos) }, // Always refetch after error or success: onSettled: () => { queryClient.invalidateQueries({ queryKey: ['todos'] }) }, })