Server State (TanStack Query)
TanStack Query (concept of the server state)
TanStack Query (previously known as react-query) is a brilliant library - an async state management solution for managing the server state. It provides API for querying and mutating data, and a lot of useful networking-related features with the caching mechanism on top of it. The great aspect of the TanStack Query library is that it provides a nice abstraction to do the networking regardless of the API standard which is used, it is capable of handling the REST API and the GraphQL API since the developer needs to provide a query/mutation function which will be used. To really get to know the mindset and motivation behind this library I really recommend reading this section.
The important thing is to understand the difference between the client and server states, which are present in the apps. The easiest way to illustrate the difference is based on examples. Let’s start with the client state, this part of the state includes information that we managed in the app for instances:
- Whether modal X is open or not.
- What is typed by the user in TextInput X.
Application owns that information, meaning that the app in some way creates them and is responsible for managing them. On the other hand, the server state is not owned by the app, the app is only requesting to get some data, make some changes to the data, or create new instances of some data. The server actually owns the data and handles any requests sent by the app in order to make some changes. Examples of the server state in the app:
- Data to display on HomeFeed.
- Information about the currently logged in user.
TanStack Query is only meant to be used for server state, its goal is to make fetching, caching, synchronizing and updating server state easier. It's not supposed to keep any client state data.
Why Tanstack Query?
Initially the platform team debated a number of options like RTK Query and SWR. Both were good options, with RTK Query being the most tempting as it ties into our current use of redux-toolkit. Ultimately however we landed on Tanstack for a number of reasons:
- Community support far outweighs that of the other options.
- The method of detecting changes to cached data (deep key comparison) is far more intuitive, and reduces the potential for footguns.
- Full structural sharing rather than identity based sharing.
- Simpler API.
- GraphQL codegen support.
All of these things, combined with how well Tanstack Query works out of the box meant it was a clear winner.
Project setup (QueryClient)
Currently, in the project, we have installed the newest available version of the TanStack Query library: v4. It is important to keep this information in mind while previewing the documentation for the library because sometimes the results in the Google search may suggest the older version (v3) where this library was called react-query. There might be some changes in the API, so those notes put there may not be up to date.
From the project perspective in the App.tsx
component we're setting a very important QueryClient which is later on provided to the QueryClientProvider
, that enables us to use this library widely in the project.
You may have multiple situations when you need access to the queryClient
object, in such cases if this access is needed from the component level please use the useQueryClient hook, otherwise it can be access by the safe refererence with the usage of getQueryClientReference
function available in the serverStateSafeReference.ts
file.
Implementation details & best practices
Within the project we're trying to stick to established set of rules, to make the usage and creation of query hooks easy to follow.
Query keys
Query keys are a key concept in the TanStack Query library. They’re responsible for identifying queries, which has an impact on caching. To manage them properly the Query Key factories pattern has been applied.
Example usage from the project, which shows the usage with params:
/* --- betStatementQueryKeys.ts --- */
const pagesKey = 'betstatement/pages';
export const betStatementQueryKeys = {
pages: (
currentPage: number,
isCurrentGroupLongPot?: string | boolean,
dateTo?: string,
dateFrom?: string,
filters?: BetStatementFilters
): BetStatementPagesKey => [pagesKey, { currentPage, isCurrentGroupLongPot, dateTo, dateFrom, filters }],
};
/* --- useBetStatementQuery.ts --- */
export const useBetStatementQuery = ({
currentPage,
isCurrentGroupLongPot = undefined,
dateTo = '',
dateFrom = '',
filters,
}) => {
// logic
return useQuery({
queryKey: betStatementQueryKeys.pages(currentPage, isCurrentGroupLongPot, dateTo, dateFrom, mappedFilters),
...
});
};
useQuery
useQuery a hook with responsibility to query data from the server. It has plenty of configuration options described in the attached documentation page. Let's preview a few most important configuration options used in the project:
queryKey
- is a obligatory parameter, which define an identifier with a set of params that needs to be include in the identifier. It should received a function from the dedicated Query Key factory as on the example above.queryFn
- (obligatory param) a query function can be literally any function that returns a promise. The promise that is returned should either resolve the data or throw an error.staleTime
- time in milliseconds after data is considered stale. This value only applies to the hook it is defined on. If that time passed the next usage of the particular query hook will result in fetching data, in order to have fresh data. If you wish to only fetch data once across the whole app, with no need to refetch pass in theNumber.POSITIVE_INFINITY
(keep in mind that this doesn't mean you can't update such cache entry).enabled
- determinate whether query can automatically run.onSuccess
- this function will fire any time the query successfully fetches new dataonError
- this function will fire if the query encounters an error and will be passed the errorselect
- this function can be used to transform or select a part of data, very important concept (described in the subsection below).
Example useQuery
usage:
const clientCashInDepositCode = async (): Promise<ICashInDepositCode | null> => {
const response = await utils().coreApi.get<ICashInDepositCode>('finance', 'blueshyft-get-active-deposit');
const codeExists = response?.code && response?.status === 'valid';
return codeExists ? response : null;
};
export const useCashInDepositCodeQuery = (): UseQueryResult<ICashInDepositCode | null, unknown> =>
useQuery({
queryKey: clientQueryKeys.cashInDepositCode,
queryFn: clientCashInDepositCode,
});
useMutation
useMutation a hook that enables performing a mutation.
mutationFn
- (obligatory param) a function that performs an asynchronous task and returns a promise.onSuccess
- this function will fire any time the query successfully fetches new dataonError
- this function will fire if the query encounters an error and will be passed the error
Example useMutation
usage:
export const createCashInDepositCodeMutationFn = async (amount: string): Promise<ICashInDepositCode | null> => {
const response = await utils().coreApi.post<ICashInDepositCode>('finance', 'blueshyft-create-deposit', { amount: Number.parseFloat(amount) });
const codeExists = response?.code && response?.status === 'valid';
return codeExists ? response : null;
};
export const useCreateCashInDepositCodeMutation = (): UseMutationResult<ICashInDepositCode | null, unknown, string, unknown> => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: createCashInDepositCodeMutationFn,
onSuccess: (data: ICashInDepositCode | null) => {
if (!data) return;
return queryClient.setQueryData(clientQueryKeys.cashInDepositCode, data);
},
});
};
onSuccess
handler in useMutation
is often used to update some queries based on our needs. In the example above we can observe that based on the incoming response from the mutationFn
, we’re overwriting the cache entry related to specific query. This is a straightforward and very common case, you’ll often need to override the full cache entry by the response from mutation, or just add another element to the cache entry based on your response: […currentState, newResponse]
.
Besides manual update you can also use the built-in possibility to invalidate queries, which will ensure that after perfomed mutation you're keeping up to date specified queries.
Another quite common case is when you optimistically update your state before performing a mutation, that's a perfect use case for mutations where instant user feedback is required.
All of the needed knowledge and comman gotchas about mutations in TanStack Query world are collected in this great blog post.
Selectors
Many well-known libraries are using the convenient pattern of selectors. TanStack Query has also a built-in functionality for selectors within the select
propety accepted by the useQuery
configuration object. Selectors are allowing us to perform some transformations on existing data or simply just select some part of the whole cache entry (which is great since it allows us to only keep track of the changes in the part of the data we need - not the full cache entry).
Let's preview a few examples:
export const clientDetailsQuerySelectors = {
// This selector is simply just taking a part of the whole cache entry
client: (clientDetailObject: ClientDetails) => clientDetailObject.client,
...
}
export const useClientDetailsQuery: ClientDetailsQueryHook = (selector) => {
return useQuery({
select: selector ?? undefined,
...
});
};
export const offersQuerySelectors = {
/*
This selector is in some way "transforming" data available in the cache entry to receive needed information:
1. Filter all offers to get actionable offers
2. Get the sum up offers value
*/
actionableOffersValue: (offers: IBonusCoupon[]): number =>
actionableOffersSelector(offers).reduce((acc, offer) => acc + offer.value, 0),
}
export const useOffersQuery: OffersQueryHook = (selector) => (
useQuery({
select: selector ?? undefined,
...
})
);
IMPORTANT: For performance reasons it's important to use stable function reference, which means that all selectors should be constructed in the same file as the particular useQuery
hook, and always imported within the useQuery
hook as needed (example composition presented above). It's recommended to avoid passing inline functions, which will have a different reference during each component rerender. Example usage:
/* --- ComponentX --- */
import { offersQuerySelectors, useOffersQuery } from '@app/features/offers/api/hooks/useOffersQuery';
export const ComponentX = () => {
const { data: couponBalance } = useOffersQuery(offersQuerySelectors.availableOffersValue);
return ....
}
Prefetching
TanStack Query gives a possibility to prefetch data that the user will need later on. That is great for a lot of performance reasons, which results in a better user experience. Prefetching might be performed for the single place in the app with the usage of prefetchQueries
method or it can have a global scale. Example of the prefetchQueries
method in action:
/* ----- Component Body ----- */
const queryClient = useQueryClient();
useEffect(() => {
void queryClient.prefetchQuery({
queryKey: racingQueryKeys.availability(eventId, false),
queryFn: () => fetchProductAvailabilityByProductGroup(eventId, false),
});
}, [raceId]);
Please keep in mind that usually the prefetchQueries
is performed based on some user interaction, such as pressing a button, or navigating somewhere.
There is also a prepared initialiseServerState
method in the initialise.ts
file, which is using a manual cache update in order to provide some necessary data to the cache while the App is being initialised (global scale).
/* --- app/config/server-state/initialise.ts --- */
export const initialiseServerState = async (queryClient: QueryClient): Promise<void> => {
// Example manual cache update (prefetch client balance in order to store it in the cache)
void queryClient.setQueryData(clientQueryKeys.balance, await clientBalanceQueryFn());
}
Focus Manager
TanStack Query uses FocusManager to manage focus state. FocusManager
is already configured in the project, by following this guide. Thanks to it there is a possibility to use refetchOnWindowFocus
option with the useQuery
configuration object, which is reacting to AppState changes.
export const useBetStatementQuery = () => {
return useQuery({
refetchOnWindowFocus: 'always',
...
});
refetchOnWindowFocus
accepts:
true
- (default) the query will refetch on window focus if the data is stale;false
- the query will not refetch on window focus;'always'
- the query will always refetch on window focus.
useCreateServerStateObserver
TODO: To be added while the final form of this tool is established
Learning resources
The official documentation is a great starting place to get some information about the TanStack Query world.
For more advanced stuff there is a great series of blog posts about various topics, usage, best practices and tricks to follow while using the TanStack Query library. This is also a remarkable place where you can often find great explanations for mechanisms used by TanStack Query under the hood.
FAQ
Why isLoading
is true even when query is disabled
Query will start in a loading state if it doesn't have data yet, even if it's not fetching because it's disabled. This is an expected behaviour in TanStack query v4.
Unfortunately, this means we cannot use isLoading
to display a loading spinner if the query is disabled, otherwise it will most likely be stuck in an infinite loading state.
The solution is to use the isInitialLoading
flag instead. It's a derived flag computed from isLoading && isFetching
, so it will only be true if the query is currently fetching for the first time.
Reference:
- https://tanstack.com/query/v4/docs/react/guides/disabling-queries#isinitialloading
- https://github.com/TanStack/query/issues/3972
Note: This unintuitive behaviour will likely change in v5, see the proposal here.
Why my query is called another 3 times when it fails
Queries that fail are silently retried 3 times. This is a default behaviour. We can configure retries both on a global level or an individual query level (e.g. retry: false
to disable retries).
Reference:
- https://tanstack.com/query/v4/docs/react/guides/query-retries
- https://tanstack.com/query/v4/docs/react/guides/important-defaults
Why I have duplicate network requests when two components requesting the same data
TanStack Query is supposed to deduplicate requests right?
Yes, but only if requests happen at the same time. TanStack Query can't deduplicate requests when two queries are mounted in the different render cycle (e.g. when a component is conditionally rendered).
And the solution is to set a staleTime
.
Reference: https://tkdodo.eu/blog/react-query-as-a-state-manager (MUST READ)
How to optimize query render
TanStack Query has the notifyOnChangeProps
option. It can be set on a per-observer level to tell TanStack Query: Please only inform this observer about changes if one of these props change.
Starting from TanStack Query v4, notifyOnChangeProps
is default to tracked
, which means only accessed properties will be tracked and the component will only re-render when one of the tracked properties change. Therefore, in most cases, there is no need to explicitly define notifyOnChangeProps
(see a few edge cases here). Feel free to use this CodeSandbox to confirm notifyOnChangeProps: tracked
works as expected.
Reference:
- https://tanstack.com/query/v4/docs/react/reference/useQuery
- https://tkdodo.eu/blog/react-query-render-optimizations#notifyonchangeprops
Why are the setQueryData
/ invalidateQueries
updates not shown?
It's most likely because query keys are not matching. Especially for queries with variables, when we call the getKey()
function, make sure the currently used variables are passed as arguments as well.
How to update query cache data in mutation's onSuccess
? setQueryData
or invalidateQueries
setQueryData
is probably the first choice on top of our head. It is pretty good when mutation already returns everything we need to know, and no network request call is needed!
However, if we have to write a complicated/inefficient setQueryData
function OR the response from mutation is not good enough, don't forget that it is fine to use invalidateQueries
to update query cache. Invalidating query is also considered the "safer" approach, especially for cases like sorted lists, which are pretty hard to update directly, as the position of a new entry could've potentially changed because of the update.
One extra network request call is better than a potentially buggy function.
Reference: https://tkdodo.eu/blog/mastering-mutations-in-react-query
I want to invalidateQueries
when query unmounts, but it triggers a query refetch
Set refetchType
to none
.
queryClient.invalidateQueries({
...
refetchType: 'none',
});
Reference: https://tanstack.com/query/v4/docs/react/reference/QueryClient#queryclientinvalidatequeries