#
Tanstack Query
Historical information around decisions and opinions on our approach to using Tanstack Query
#
Required Reading
Practical React Query blog series by Dominik Dorfmeister, the primary maintainer of the library.
#
Transition from Redux to Tanstack Query
We utilized Redux and specifically Redux Toolkit for much of the early history of MidFirst Bank React applications. While this served us well for the time being, managing all of the lifecycle of async thunks, loading/error states, and data manipulation inside of Redux proved to become rather cumbersome at larger scales.
The discovery of Tanstack Query as an out of the box solution to tuck away much of the boilerplate of managing asynchronous state without losing flexibility was an easy decision.
#
Mantras
- Always wrap in a custom hook
- After a mutation, prefer cache invalidation over surgically updating the cache.
- Call Axios from directly within your custom hook, instead of having a full API client class layer
- Prefer
useErrorBoundaryover handling theerrorstate in every component.
#
Custom Hooks
While it may seem counter-intuitive, you should always wrap your useQuery or useMutation hooks in a custom hook. This has several benefits, including but not limited to:
- You can share the actual data fetching code. Instead of calling your api in each component where it is needed, you can colocate that code with the
useQuerycall - You can co-locate your query key definitions with your
useQuerycall. Grouping these things together is helpful, because they will almost always be edited at the same time. - If you need to tweak options for a particular API call, you do it in one place and all of your usages will get it.
- You keep your component code much cleaner by hiding away all of your Types and configuration.
#
Example Custom Hook file
import axios, { AxiosInstance } from "axios";
import { tokenManager } from "mfb-client-authz";
// code for setting up Axios instance with authentication
const axiosInstance: AxiosInstance = axios.create({
baseURL: "https://example.dev",
headers: { "Content-Type": "application/json" },
});
axiosInstance.interceptors.request.use((config) => {
const token = tokenManager.getToken();
if (token) config.headers.set("Authorization", `Bearer ${token}`);
return config;
});
// code for your query key factory
export const userQueryKeys = {
all: () => ["user"],
details: () => [...userQueryKeys.all(), "details"],
detail: (id: string) => [...userQueryKeys.details(), id],
};
// your actual custom hook(s)
export const useGetUser = (
id: string,
options?: UseQueryOptions<User, AxiosError<ErrorDetails>>
) => {
return useQuery<User, AxiosError<ErrorDetails>>(
userQueryKeys.detail(id),
({ signal }) => {
const { data } = await axiosInstance.get<User>(`/user/${id}`, { signal });
return data;
},
options
);
};
#
Query Key Factories
A vital article to read when thinking about how to structure your query keys can be found here.
A few high points are:
- Always use Array Keys
- Structure your Query Keys from most generic to most specific, with as many levels of granularity as you see fit in between.
- One Query Key factory per feature.
export const userQueryKeys = {
all: () => ["user"],
// multiple levels of nesting
lists: () => [...userQueryKeys.all(), "lists"],
list: (params: UserArgs) => [...userQueryKeys.lists(), params],
corpTechUsers: () => [...userQueryKeys.lists(), "corpTechUsers"],
byDepartment: (department) => [
...userQueryKeys.lists(),
"departmentUsers",
department,
],
// single level of nesting
details: () => [...userQueryKeys.all(), "details"],
detail: (id: string) => [...userQueryKeys.details(), id],
};
#
Hook UseQueryOptions
The useQuery hook usage has an options argument that provides configuration around how the hook works. These options may or may not be exposed via the wrapping custom hook. Depending how much you want to expose to the consumer, you should type the options argument appropriately.
This can be done with the provided UseQueryOptions generic type from the library. It requires two generic arguments be passed to it: the query function data type (type of the response from the Promise), and a type describing the error.
/*
* `User` here is the response type from the server
*
* If the promise provided to the query function is an Axios method,
* it will error with an `AxiosError`
*
* AxiosError receives a type describing our standard error interface
*
* The same generic arguments have to be passed to the `useQuery` call
* the options are created for
*
*/
const useGetUser = (
options: UseQueryOptions<User, AxiosError<ErrorDetails>>
) => {
return useQuery<User, AxiosError<ErrorDetails>>({
queryKey: "query_key",
queryFn: () => {},
options,
});
};
#
Mutations
In Tanstack Query, useQuery is declarative, useMutation is imperative, i.e. queries mostly run automatically while mutations are only ran after an action is taken by the user. One of the items returned from the useMutation hook is a mutate function that will trigger the mutation function to run.
Another primary difference between useQuery and useMutation is that mutations do not share state in the same way that queries do. If you invoke useQuery in multiple different components, it will always return the same data/state across all of those components, but this is not true of mutations. Each instance contains it's own state, i.e. it's own isLoading and error values.
Just like with queries, you always want to wrap your useMutation call in a custom hook.
export const useDeleteUser = () => {
return useMutation<
DeleteUserResponse,
AxiosError<ErrorDetails>,
{ id: string }
>((id: string) => {
const { data } = await axiosInstance.delete<DeleteUserResponse>(
`/v1/user/${id}`,
{
merchantId: "",
}
);
return data;
});
};
#
Cache Invalidation Strategies
After you mutate data, you will likely need to consider invalidating your cache of existing data. If you have a List of Users, and then delete one of those users, you will continue displaying the old list including your deleted User unless you tell the application to update.
There are two main strategies for handling this scenario:
- Invalidate the cache for the list, which will trigger Tanstack Query to automatically start fetching the new list
- Surgically update the data inside the cache to remove the item from the cache
For most uses cases, we prefer invalidation of the cache instead of surgically updating. While it will result in a new API call, it guards against potentially missing a place where the mutated object is in the cache.
// Invalidating the Cache
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation<
UpdateUserResponse,
AxiosError<ErrorDetails>,
{ id: string; key: value }
>(
(args) => {
const { id, ...rest } = args;
const { data } = await axiosInstance.patch<UpdateUserResponse>(
`/v1/user/${id}`,
args
);
return data;
},
{
onSuccess: (response) => {
void queryClient.invalidateQueries(queryKeys.lists());
},
}
);
};
// Surgically updating the cache
export const useUpdateUser = () => {
const queryClient = useQueryClient();
return useMutation<
UpdateUserResponse,
AxiosError<ErrorDetails>,
{ id: string; key: value }
>(
(args) => {
const { id, ...rest } = args;
const { data } = await axiosInstance.patch<UpdateUserResponse>(
`/v1/user/${id}`,
args
);
return data;
},
{
onSuccess: (response, args) => {
void queryClient.setQueryData(queryKeys.detail(args.id), response);
},
}
);
};
#
Errors
Tanstack query comes with a really neat feature for errors, especially in regards to using ErrorBoundary components. By default, any useQuery call will catch errors that are thrown by your provided query function and stick those into the error object returned by the hook. This is a nice default because it means you don't really have to worry about catching errors, only need to worry about presenting the error in your UI through your components.
However, this can be painful to have to deal with the error state across many components that read from the same query. We like things to be uniform and to present errors in a consistent way. For this, useQuery provides the useErrorBoundary option key. If useErrorBoundary is set to true, the hook will re-throw the error on the next component render, allowing it to bubble up to an ErrorBoundary component in your JSX tree.
// in hooks.ts
const useGetUser = (options: UseQueryOptions<User, AxiosError<ErrorDetails>>) => {
return useQuery<User, AxiosError<ErrorDetails>>({
queryKey: 'query_key',
queryFn: () => {},
{
useErrorBoundary: true,
...options
},
})
}
// in App.tsx
const App = () => {
return (
<ErrorBoundary fallback={<p>There was an error fetching this user.</p>}>
<User />
</ErrorBoundary>
);
}
// in User.tsx
const User = () => {
const { data } = useGetUser();
return <p>data.name</p>
}
#
Testing
Tanstack Query custom hooks are relatively easy to test, but do require a bit of setup to ensure that you have everything you need to call the hook as if it were in a component inside your app.
First, you need to ensure that the hook is called from withing a QueryClientProvider. The renderHook method from @testing-library/react provides a wrapper option key, that allows you to provide a wrapper around your hook. This should live in some sort of test utility file. This is also a good place to wrap with any sort of AuthenticationProvider your app may have as well.
export const renderHookWithProviders = () => {
const client = new QueryClient(queryClientOptions);
return function RenderedComponent({ children }: { children: ReactNode }) {
return (
<AuthenticationProvider>
<QueryClientProvider client={client}>{children}</QueryClientProvider>
</AuthenticationProvider>
);
};
};
Once you have this wrapper function configured, you can import that into your test, along with your mock data and mock server that comes from MSW. Your test should cover both a success and error case.
If your hook utilizes useErrorBoundary: true, you will need to set this to false for your error case, or else the hook will throw and cause your test to fail. You want the hook to store the error in state so you can check for it.
import { rest } from "msw";
import { renderHook, waitFor } from "@testing-library/react";
import { renderHookWithProviders } from "../path/to/your/render/utils";
import { mockServer } from "../path/to/your/mockServer";
import { mockGetUserResponse } from "../path/to/your/mockUsers";
import { useGetUser } from ".";
describe("User hooks", () => {
describe("useGetUser", () => {
it("should handle successful request", async () => {
const { result } = renderHook(() => useGetUser({ id: "1" }), {
wrapper: renderHookWithProviders(),
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toStrictEqual(mockGetUserResponse);
});
it("should handle failed request", async () => {
mockServer.use(
rest.get("*", (req, res, ctx) => {
return res(ctx.status(500));
})
);
const { result } = renderHook(
() => useGetUser({ id: "1" }, { useErrorBoundary: false }),
{
wrapper: renderHookWithProviders(),
}
);
await waitFor(() => expect(result.current.isError).toBe(true));
});
});
});
#
Extras
#
useQueries
If you need to run multiple queries dynamically, you can use useQueries. This works almost exactly like useQuery, but instead of running a single query, you can pass it a dynamic array of queries. It returns an array of query results, in the same order as the queries you passed in. Its behavior is much like Promise.all(), where it waits to resolve until all the queries have settled.
While this isn't something that we'd have need for very often with how our code is typically structured, it is a useful tool for running queries dynamically without violating the rules of hooks.