React Query is a very strong library that helps us use a REST API without the need to worry about states and reducers, when to refresh your data, or how to manually manage caching, thanks to (among other things) a cool query key system.
However, the library is not mistake-proof, and you can find yourself forgetting parameters in your complex query key, or forgetting to invalidate another dependent query when you update your data.
That where TypeScript comes in handy by letting you solve this problem and fill the final gap in React Query.
React Query is great but prone to mistakes
First of all, what is React Query?
Well according to them:
React Query is often described as the missing data-fetching library for React, but in more technical terms, it makes fetching, caching, synchronising and updating server state in your React applications a breeze.
Out of the box, React applications do not come with an opinionated way of fetching or updating data from your components so developers end up building their own ways of fetching data. This usually means cobbling together component-based state and effect using React hooks, or using more general purpose state management libraries to store and provide asynchronous data throughout their apps.
So if you were wondering how to manage the server state in your app client, to cache and update stale data in the background while making your front-end logic code simpler, React Query might be worth checking.
Here is an example from React Query documentation that shows how it can be used to handle the loading and error statuses of your query. This is not what we will be focusing on in this article, but if you have never worked with this library before, it will give you an idea of how it is used:
import { QueryClient, QueryClientProvider, useQuery } from 'react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Example />
</QueryClientProvider>
)
}
function Example() {
const { isLoading, error, data } = useQuery('repoData', () =>
fetchGitHubRepoData()
)
if (isLoading) return 'Loading...'
if (error) return 'An error has occurred: ' + error.message
return (
<div>
<h1>{data.name}</h1>
<p>{data.description}</p>
<strong>👀 {data.subscribers_count}</strong>
<strong>✨ {data.stargazers_count}</strong>
<strong>🍴 {data.forks_count}</strong>
</div>;
)
}
const fetchGitHubRepoData = () => fetch('https://api.github.com/repos/tannerlinsley/react-query').then(res => res.json());
Note that this library is compatible with React and React Native.
The query key format and its functioning
The first parameter passed to 'useQuery' is the query key, which is actually the central element in React Query’s API and enables it to fetch, cache, know when data is out-of-date, and refresh. This is also what we will be focusing on because it holds such an important role for our apps while being a tiny element.
First, it can either be a simple string, as in the example above ('repoData'), or an array of different parameters:
What are these parameters? Let’s say we now want to be able to query data of different GitHub repositories. We may change our query function to be able to pass the repo as a parameter:
const fetchGitHubRepoData = (repo: string) => fetch(`https://api.github.com/repos/${repo}`).then(res => res.json());
The query key should become:
The string prefix can be used across the whole React Query’s API to manage the state, caching, fetching, retry,... of this query. To me, it also helps give a human-readable meaning to your key.
The other parameters are the ones on which your query result depends. Typically they should include every parameter given to your query function. The query will automatically refresh your data when any part of the key changes, and store the result in its internal cache according to the key.
I personally chose to have a tuple instead of a variable-length array, keeping all other variables in an object. To read more about that choice, this documentation page is clear and concise about React Query keys.
Our query keys will thus all follow this format:
[prefix: string, vars: {}]
Let’s take a real-life example that we will follow to see how to improve it.
At Hokla we are working on several projects for different MedTech clients. One of them is a mobile app that companions cancer patients, monitors their health thanks to symptoms checking, and offers them short articles written by physicians to help them go through their day-to-day difficulties.
This app recommends some of its articles for the patients to read according to various criteria. These articles are organised by category, and our front-end fetches them one category at a time. It also enables fetching a certain number of articles per category.
const fetchRecommendedArticles = (options: {categoryId: number, limit?: number}) => {}
Considering what we said before, the correct query key to use should be, for example:
const articlesQueryKey = ["recommendedArticles", {categoryId: 1, limit: 10}]
Ok, great! Now our queries can work perfectly and we can reuse this template as much as we want.
But what happens if we forget one parameter? What will happen when we modify an API call and forget to update the keys in accordance? Spoiler alert: it goes wrong!
Is it really a bug to forget a variable in the query key? Isn’t it only to optimize React Query’s internal state?
Short answer: no. This WILL create a defect in your app, and it does functionally impact the results of your queries and what your users will see.
Let me tell you about one of my bugs.
During a big refactoring session around the recommended articles functionality, we removed some parameters from our query key because they were no longer necessary.
We ended up with this implementation of the articles query:
const RECOMMENDED_ARTICLES_QUERY_KEY = "recommendedArticles";
export const useRecommendedArticlesByCategory = (
options: { categoryId: number; limit?: number }
): UseRecommendedArticlesHookResult => {
const recommendedArticlesQuery = useQuery(
RECOMMENDED_ARTICLES_QUERY_KEY,
() => fetchRecommendedArticles(options),
);
return {
recommendedArticles: recommendedArticlesQuery.data ?? [],
isFetching: recommendedArticlesQuery.isFetching,
};
};
From what we saw earlier, one can easily guess what happened.
Each time the query was called, for every article category, React Query would actually:
- start the query
- check the given query key
- lookup in its cache if it already had a ‘fresh’ result for that particular query
- serve the cached result
Because the query key is static and does not depend on any other parameter, React Query would not fire a refresh with the provided query function, even though the result would be different since the options were actually changing every time. We ended up having the same batch of articles in all categories, all because of a missing parameter in the query key.
So how can we ensure we always have correct keys in all queries, including the correct set of parameters? We will see how to do that without making the life of the developer harder. On the contrary, you will actually not need to think about all of that while coding after setting your typing once and for all.
Make strong query keys with TypeScript
So, how can we make sure the query key of all your queries, which is the ...key element in React Query’s cache system, always includes all of our parameters?
Use Union Types and extending interfaces to mistake-proof your keys
There are a few ways you could find to type your query keys since React Query accepts different formats, and because TypeScript also offers us an important set of tools.
To prove to you how permissive the bare query key typing is, this is the definition from the library:
export declare type QueryKey = string | readonly unknown[];
export declare function useQuery(queryKey: TQueryKey, queryFn: QueryFunction, options?: UseQueryOptions): UseQueryResult;
So, even though React Query is great, we need to harden the typing because it is clear that is it easy to make mistakes for now.
I do it with a straightforward approach involving Union Types and extending interfaces. But I also don’t want to manually write tuples every time I need to build a key, destructure my prop objects, and build a wild tuple in the middle of my code. I really prefer using objects.
So we’ll split the representations:
- a tuple format used only to feed React Query
- and an object format to use ourselves
export type QueryKeyTuple = [QueryKey["prefix"], QueryKey["vars"]];
export type QueryKey =
| RecommendedArticlesQueryKey
| ProfileQueryKey
| AvailableSymptomsQueryKey;
| SymptomEventsQueryKey;
interface QueryKeyDefinition {
prefix: string;
vars: Record;
}
export interface RecommendedArticlesQueryKey extends QueryKeyDefinition {
prefix: "recommendedArticles";
vars: {
categoryId: number;
limit?: number
};
}
// define all needed query keys in the same format, extending `BaseQueryKey`
...
We now have object key types that describe all needed parameters for our query, and a tuple that we have to feed useQuery.
If you’re wondering why we can’t write directly our keys as a tuple: we actually could. But then nothing would force us to correctly type it. Instead, we can have a helper function to transform the format and at the same time check that we have the correct params.
Create a helper function to use in your queries
We are still missing a piece that is going to adapt our format and do their type checking for us.
This helper function is as simple as this:
/**
* Build query key for React Query and enforce param type cohesion with key.
*/
export function getQueryKey({ prefix, vars }: QueryKey): QueryKeyTuple {
return [prefix, vars];
}
We use the Union Type QueryKey to generically type our input key. TypeScript then infers the underlying type from the union based on the string prefix field. As a result, the vars field is already typed, not letting us write incorrect params, and even letting us autocomplete our key in a couple of seconds. Try it, and see that you will not be able to write params from another query instead.
I’m sure you now know how to use your type keys now, but let’s see how our recommended articles example is going:
export const useRecommendedArticlesByCategory = (
options: { categoryId: number; limit?: number }
): UseRecommendedArticlesHookResult => {
const recommendedArticlesQuery = useQuery(
getQueryKey({
prefix: "recommendedArticles",
vars: options,
}),
() => fetchRecommendedArticles(options),
);
return {
recommendedArticles: recommendedArticlesQuery.data ?? [],
isFetching: recommendedArticlesQuery.isFetching,
};
};
It is fine to use this getQueryKey function like this. But if you don’t want you or other developers forgetting to manually call it every time there is a new query, you could help your team and your project furthermore.
Go further with a hook replacement to forbid using untyped keys
To prevent not using typed keys and to have this query type check done automatically, you can write a wrapper hook around the stock useQuery. Please feel free to challenge the naming but this is how it can be done (the referenced types are from React Query’s API):
export function useTypedQuery(
queryKey: QueryKey,
queryFn: QueryFunction,
options?: UseQueryOptions,
): UseQueryResult {
return useQuery(
getQueryKey(queryKey),
queryFn,
options
);
}
Note that while this hook uses Generic Types, you will never have to explicitly type it when calling it, since TypeScript will successfully infer the correct type from the given arguments.
👉 I used AxiosError here since all my calls are made with Axios, but you can replace it statically with anything else, or provide the error type in the Generic typing of the hook as well, for it to be inferred.
This gives us the simple use:
export const useRecommendedArticlesByCategory = (
options: { categoryId: number; limit?: number }
): RecommendedArticlesHookResult => {
const recommendedArticlesQuery = useTypedQuery(
{
key: "recommendedArticles",
params: options,
},
() => fetchRecommendedArticles(type, options),
);
return {
recommendedArticles: recommendedArticlesQuery.data ?? [],
isFetching: recommendedArticlesQuery.isFetching,
};
};
If you want to explicitly forbid to not use your type query hook, add a custom linter rule to your project. If you use ESLint, check out this plugin.
Ok, we’ve seen how to prevent your cached data to be out of sync compared to parameters on which your query depends. But now how to prevent that your data is always fresh and synced with the server when you actually mutate things from the client-side?
Never miss a mutation dependency
React Query’s API lets you change (create, update or delete) your data on the server-side with POST/PUT/PATCH/DELETE calls thanks to “mutations” and the 'useMutations' hook. It can be as simple as:
const UpdateProfileScreen = (): JSX.Element => {
const patchProfileMutation = useMutation(patchProfile);
return {
...
<Button title="Confirm" onPress={() => patchProfileMutation.mutate({gender: newGender})}/>
...
}
}
Let’s take back our articles example to set the context. Remember that the articles recommended to one user depend on the profile of said user. Thus, if they modifies their profile, the recommended articles are also changed and should be updated in the front. If you start having a lot of queries and mutations this in your app, it will rapidly become very complex to remember and track every dependency and when to refresh which resource.
To refresh and fetch your data again after mutating them server-side you can do that with React Query’s QueryClient.invalidateQueries. The manual and naive way would be:
const queryClient = useQueryCLient();
function invalidateProfileDependentQueries() {
queryClient.invalidateQueries("profile");
queryClient.invalidateQueries("recommendedArticles");
queryClient.invalidateQueries("availableSymptoms");
}
const patchProfileMutation = useMutation(patchProfile, {
onSuccess: invalidateProfileDependentQueries,
});
Here is how to type this invalidation and assure you invalidate every query depending on the resources you mutate.
Build a similar type system with Generics, interface extension and Union Types
Let’s start by building our mutation type system and store all of our mutation dependency information for static type checking.
We will actually use the same TypeScript tools than previously, a Union Type and extending interfaces:
export type TypedMutation =
| ProfileTypedMutation
... // every possible mutation in your app. Could also be split by module
| SymptomEventsTypedMutation
export type TypedMutationDefinition = {
dependentQueryKeys: QueryKey["key"][];
mutationFn: (params: [TParams]) => Promise;
returnType: TReturn;
paramsType: TParams;
};
export interface ProfileTypedMutation
extends TypedMutationDefinition<
Partial,
Profile
> {
dependentQueryKeys: [
ProfileQueryKey["key"],
➡️ RecommendedArticlesQueryKey["key"],
AvailableSymptomsQueryKey["key"]
];
}
...
We created a generic type that holds information about all our mutations, mainly the query keys that might depend on the mutated resource and also checking the input and output types of your REST API functions.
Notice that I always include the query fetching that resource itself in order for this resource to actually be updated in our front. You could also not include it here for it to be invalidated and instead use React Query’s optimistic update.
To correctly use the 'dependentQueryKeys' field, let’s give us another small helper function to actually invalidate said queries.
Write a helper function to invalidate all queries that need invalidation without even thinking about it
React Query’s invalidate queries is quite simple. Give it a query key and it will consider the previously fetched and cached result is now stale and fetch your data again.
The subtility is that you can pass an entire complex query key with all its specific variables or stay generic while passing it only a query key prefix. All queries using that prefix will be invalidated. This is this mechanism that we will be using thanks to our custom, home made types.
export function invalidateDependentQueries(
dependentQueries: T["dependentQueryKeys"],
queryClient: QueryClient
): void {
for (const dependentQuery of dependentQueries) {
queryClient.invalidateQueries(dependentQuery);
}
}
Using this function, you can safely invalidate everything that depends on your resource. More, when you implement a mutation on one resource, no need to go check the model structure, or architecture drawing or documentation. You dependent queries tuple is ready for autocompletion.
You may be thinking that unfortunately, you will have to manually call this function everytime and make sure your mutation was indeed successful before doing so... Or you could read one more paragraph and see how React Query actually let’s you do so quite easily, and automate it.
Go further with a hook replacement not to even have to implement invalidating for every mutation
I promise, this is really the last typing we will see. The idea is the same as for the query hook, which is replacing it by our own hook that holds the typing check.
Moreover here our hook will actually also take care or invalidating our queries for us:
export type UseTypedMutationOptions = {
options: UseMutationOptions & {
mutationFn: MutationFunction;
};
dependentQueryKeys: T["dependentQueryKeys"];
queryClient: QueryClient;
};
export type UseTypedMutationResult = UseMutationResult<
T["returnType"],
unknown,
T["paramsType"],
unknown
>;
export function useTypedMutation({
options,
dependentQueryKeys,
queryClient,
}: UseTypedMutationOptions): UseTypedMutationResult {
return useMutation({
...options,
onSuccess: (data, variables, context) => {
invalidateDependentQueries(dependentQueryKeys, queryClient);
return options.onSuccess?.(data, variables, context);
},
});
}
We just inserted our invalidation function inside the onSuccess function from the React Query mutation options while keeping our custom type and preserving the minimal base type of the API.
Like before, you can create your own linter rule to forbid yourself from using the untyped useMutation if you want to.
Now, mutating your resources is as simple as:
const patchProfileMutation = useTypedMutation({
options: { mutationFn: patchProfile },
dependentQueryKeys: ["profile", "recommendedArticles", "availableSymptoms"],
queryClient,
});
And yes, the dependent query keys tuple is still correctly typed and ready for autocomplete.
If you forget a query key, TypeScript won’t let you get away with it:
To sum up
We’ve seen that React Query is a truly powerful library that enables to manage your server state. Nonetheless, there was still one pitfall every developer on the project should be mindful about, that is matching exactly what you feed their API with the correct dependencies between all your data.
When the project becomes more complex or you onboard new developers, it could actually be better to invest one hour hardening your use of this library so that nobody can make any mistake ever again while making it even easier to use. No more thinking about your data while you are coding. Let TypeScript and your IDE autocomplete handle your keys and mutation dependencies.
Making a mistake in the use of this library or another can seem like a little thing. But a bug, or any defect in that matter, is actually en evidence that something might not be coded the right way and that there is a room left to make other similar mistakes.
At Hokla we focus on the quality of the products we develop since their use impacts patients’ lives. Because of this focus, we try to completely suppress the possibility for any defect to happen again in the project by implementing a mistake proof system, also called “poka-yoke”, around the failed piece of code. Typing React Query was just one example, but TypeScript is actually a perfect tool to use the poka-yoke method. You can type a lot of other libraries that you may find are not that secured on the typing point-of-view.
If you’ve read until here, congratulations! It seems you too want to prevent yourself and other developers from making type mistakes. Tell me, do you have any useful tip in using TypeScript in your projects? Please reach out to me, check my contact info below :)
More about Poka-Yoke, one of many aspects of Jidoka:
A great blog about React Query: