TanStack Query and Mutation templates for React RSC/SSR/RCC

TanStack Query and Mutation templates for React RSC/SSR/RCC

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.