Recommendations on how to structure TanStack queries and mutations. Provided templates define hooks with extensible interfaces, supporting custom fetch functions per request. RSC/SSR/RCC compatible.
Expectations
Compatible with some RSC/SSR frameworks - fetch requests should exist independently.
Good invalidation possibilities - prefix and predicate statements defined in one place.
Axios library (with a simple replacement with native fetch, e.g. in Next.js case).
There is no way to generate hooks for Your project, e.g. via GraphQL Codegen. See TanStack Docs.
Project structure
Globally shared hooks
One folder to rule them all:
~/src/queries
(or separate mutations into their own~src/mutations
folder).It is somehow similar to the result of a code generator.
Component-based hooks
The main concept is high cohesion - hooks are placed according to usage as near as possible to the components that import them.
For more details, see React folder structure.
Each file is named according to its react hook and exports all types, fetch requests and the hook itself. For example, we have a REST endpoint GET /api/v1/users
. Method GET
and unique entity name Users
are included in the final hook name.
useQuery(HttpMethod)(Entity).tsx
useQueryGetUsers.tsx
The same naming convention is applied to mutations:
useMutation(HttpMethod)(Entity).tsx
useMutationPatchUser.tsx
In case of queries we also need somehow to manage and store query keys and invalidation groups. Never define them inside hooks. The whole set of keys EOT will become a mess in this case. Query keys are similar to primary keys in databases, so it is good to normalize them, see database normal forms - 1NF, 2NF, and 3NF (normalization is not required, but it helps a lot).
Query keys and invalidation groups
It is a good idea to define one file that will manage all query keys across an app.
// ~/src/queries/_constants.ts
import { Query } from '@tanstack/react-query'
type Filter = string | number | null
/**
* Unique query keys
*/
export const QK = {
getSomeEntity: ['group', 'key'],
getSomeEntityWithFilter: (filters: Filter[]) => ['group', 'key', ...filters],
} as const
/**
* Query invalidators (by prefix and by predicate)
*/
export const QK_INVALIDATION = {
// prefix based
prefixPreciseKey: QK.getSomeEntity,
prefixFixedKey: ['group'],
// predicate based
predicateSomeKeys: (query: Query) => query.queryKey.includes('organizationContext'),
} as const
useQuery template
// ~/src/queries/useQueryGetEntity.ts
import { useQuery, keepPreviousData } from '@tanstack/react-query'
import { AxiosInstance } from 'axios'
/**
* *ReqStData
* Request static data - used for a request creation.
*/
export type GetEntityReqStData = {
id: number
}
/**
* *ResData
* Response data - returned from the server.
*/
export type GetEntityResData = {
id: number
name: string
}
/**
* *Req
* Request - stand-alone fetch. For SSR and RSC pre-fetch purposes.
* @param axios - Axios client
* @param reqStData - Static data used for request creation
*/
export const getEntityReq =
({
axios,
reqStData,
}: {
axios: AxiosInstance
reqStData: GetEntityReqStData
}) =>
() =>
axios
.get<GetEntityResData>(`/api/mock/users/${reqStData.id}`)
.then((res) => res.data)
/**
* *QueryStData
* Query static data - used for query and request creation.
*/
export type GetEntityQueryStData = {
reqStData: GetEntityReqStData
}
/**
* useQuery*
* React hook - calls RQ with fetch request.
* @param axios - Axios client
* @param queryStData - Static data used for query creation.
*/
export const useQueryGetEntity = ({
axios,
queryStData,
}: {
axios: AxiosInstance
queryStData: GetEntityQueryStData
}) => {
return useQuery({
queryKey: ['group', 'key', queryStData.reqStData.id], // QK.something
queryFn: getEntityReq({ axios, reqStData: queryStData.reqStData }),
placeholderData: keepPreviousData,
})
}
useMutation template
// ~/src/queries/useMutationPatchEntity.ts
import { useMutation } from '@tanstack/react-query'
import { AxiosInstance } from 'axios'
/**
* *ReqStData
* Request static data - used for a request creation. Data not changing with each request (e.g. ID of the entity).
*/
export type PatchEntityReqStData = {
id: number
}
/**
* *ReqDynData
* Request dynamic data - used for a request call (e.g. user input).
*/
export type PatchEntityReqDynData = {
name: string
}
/**
* *ResData
* Response data - returned from the server.
*/
export type PatchEntityResData = {
id: number
name: string
}
/**
* *Req
* Request - stand-alone fetch. For SSR and RSC pre-fetch purposes.
* @param axios - Axios client
* @param reqStData - Request static data
* @param () => reqDynamicData - Request dynamic data
*/
export const patchEntityRequest =
({
axios,
reqStData,
}: {
axios: AxiosInstance
reqStData: PatchEntityReqStData
}) =>
(reqDynData: PatchEntityReqDynData) =>
axios
.patch<PatchEntityResData>(`/api/mock/users/${reqStData.id}`, reqDynData)
.then((res) => res.data)
/**
* *MutationStData
* Mutation static data - used for mutation and request creation.
*/
export type PatchEntityMutationStData = {
reqStData: PatchEntityReqStData
}
/**
* useMutation*
* React hook - calls RQ with fetch request.
* @param axios - Axios client
* @param mutationStData - Static data used for mutation creation.
*/
export const useMutationPatchEntity = ({
axios,
mutationStData,
}: {
axios: AxiosInstance
mutationStData: PatchEntityMutationStData
}) =>
useMutation({
mutationFn: patchEntityRequest({
axios,
reqStData: mutationStData.reqStData,
}),
})
Usage
Both templates are almost agnostic and should be suited for each request. Let's say that we have an endpoint for user private projects:
GET /api/v2/users/project?type=private
Our hook is called just useQueryUserProjects
to make it universal because of the possibility of some public projects, etc. We need static data with project type, a response type is generated from OpenAPI definition, there is no need to configure fetch request with each hook call (just request itself, because of server calls):
type Filter = string | number | null
export const QK = {
// ...
getUserProjects: (filters: Filter[]) => ['users', 'projects', ...filters],
} as const
// optional
export const QK_INVALIDATION = {
prefixUsers: ['users'],
prefixUserProjects: ['users', 'projects'],
} as const
// ~/src/queries/useQueryGetUserProjects.ts
import { useQuery, keepPreviousData } from '@tanstack/react-query';
import { AxiosInstance } from 'axios';
import ApiTypes from '@swagger-types';
import axiosSsrClient = '@axios-ssr-client'
import {QK} from '~/src/queries/_constants.ts'
export type GetUserProjectsReqStData = {
type?: string;
};
export type GetUserProjectsResData =
ApiTypes.paths['/api/v2/users/project']['get']['responses']['200']['content']['application/json'];
export const getUserProjectsReq =
({ axios, reqStData }: { axios: AxiosInstance; reqStData: GetUserProjectsReqStData }) =>
() =>
axios
.get<GetUserProjectsResData>('/api/v2/users/project', {
params: {
type: reqStData.type,
},
})
.then((res) => res.data);
export type GetUserProjectsQueryStData = {
reqStData: GetUserProjectsReqStData;
};
export const useQueryGetUserProjects = ({
queryStData,
}: {
queryStData: GetUserProjectsQueryStData;
}) => {
// [type]
const filter = [queryStData.reqStData.type];
return useQuery({
queryKey: QK.getUserProjects(filter), // QK.something
queryFn: getUserProjectsReq({ axios: axiosSsrClient, reqStData: queryStData.reqStData }),
placeholderData: keepPreviousData,
});
};
The original interface is preserved - it has consistency, and it is easy to extend. At first it may feel that it is a lot of code for just one simple request - yes, deal with it. It may be shorter, but then it is less extendable, and EOT each request is going to have its own special way how to call it.