Skip to main content

Command Palette

Search for a command to run...

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

Updated
7 min read
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 (or query option). 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

We also need to manage and store query keys and invalidation groups in case of queries. 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/configs/tsq.ts

import type { Query, QueryKey } from '@tanstack/react-query';

type Filters = { [key: string]: string | number | undefined };
type QueryKeyList = { [key: string]: (...args: never[]) => QueryKey };
type QueryKeyPredicate = (query: Query) => boolean;
type QueryKeyInvalidationList = {
  [key: string]: QueryKey | QueryKeyPredicate | ((...args: never[]) => QueryKeyPredicate);
};

/**
 * List of QueryKeys
 */
export const QK = {
  // Examples
  // getSomeEntity:                  ()                                => ['group', 'key'],
  // getSomeEntityWithFilter:        (filters: Filters)                => ['group', 'key', filters],
} as const satisfies QueryKeyList;

/**
 * Query invalidators (by prefix and by predicate)
 * See https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation
 */
export const QK_INVALIDATION = {
  // Examples
  // prefixKeyFromQK:   QK.getSomeEntity(),
  // prefixKeyCustom:   ['group'],
  // predicateKey:      (query: Query) => query.queryKey.includes('organizationContext'),
  // genPredicateKey:   (pageSize: number) => (query: Query) => (query.queryKey[1] as {[key: string]: unknown})?.pageSize === pageSize,
} as const satisfies QueryKeyInvalidationList;

useQuery template

// ~/src/queries/useQueryGetEntity.ts

import { keepPreviousData, queryOptions } from '@tanstack/react-query';
import { AxiosInstance } from 'axios';
import { QK } from '~/src/configs/tsq.ts';

/**
 * useQuery<MethodEntity>.ts
 *
 * <MethodEntity> | <methodEntity>
 * Choose a method from API: GET, POST, PUT, DELETE, PATCH, ... that represents action.
 * Choose an entity name: User, OrderList, Orders, InstitutionEnum, ... that represents a resource.
 * These names are used to create a name <MethodEntity> for the API endpoints: GetInstitutionEnum, PostUser, ...
 */

/**
 * <MethodEntity>RequestData (optional)
 * Request data - used for a request creation.
 */
export type GetEntityRequestData = {
  id: number;
  queryParams: {
    // Pagination
    page: number;
    limit: 10 | 25 | 50 | 100;
    // Filters
    search?: string;
  };
};

/**
 * <MethodEntity>ResponseData (required)
 * Response data - returned from the server (inside body).
 * This type should not be declared anywhere else and should not use any external type declarations (out of this file).
 * This file is a single source of truth that defines a behavior of the endpoint.
 */
export type GetEntityResponseData = {
  id: number;
  name: string;
  quantity: number;
};

/**
 * <methodEntity>Request (required)
 * Request - stand-alone request. For SSR and RSC pre-fetch purposes.
 * @param axios - Axios client. It is parametrized to allow SSR and RSC to use different Axios instances.
 * @param requestData - Static data used for request creation.
 * @returns Callable function for a request.
 */
export const getEntityRequest =
  ({ axios, requestData }: { axios: AxiosInstance; requestData: GetEntityRequestData }) =>
  () =>
    axios.get<GetEntityResponseData>(`/api/mock/users/${requestData.id}`).then((res) => res.data);

/**
 * <MethodEntity>QueryRequestData (optional)
 * Query options - used for query config creation (server prefetch and client fetch).
 */
export type GetEntityQueryRequestData = {
  requestData: GetEntityRequestData; // required if request is parametrized
};

/**
 * <methodEntity>QueryOptions (required)
 * Query options - used for query creation (server prefetch and client fetch).
 * @param axios - Axios client.
 * @param queryRequestData - Static data used for query creation. Includes request data.
 * @param select - Custom selector.
 */
export const getEntityQueryOptions = <T>({
  axios,
  queryRequestData,
  select,
}: {
  axios: AxiosInstance;
  queryRequestData: GetEntityQueryRequestData;
  select?: (data: GetEntityResponseData) => T;
}) =>
  queryOptions({
    queryKey: QK.enumProducts(),
    queryFn: getEntityRequest({ axios, requestData: queryRequestData.requestData }),
    placeholderData: keepPreviousData,
    select,
  });

// Custom selectors that can be used with query options.

export const selectGetEntityItemsQuantity = (data: GetEntityResponseData) => data.quantity;

useMutation template

// ~/src/mutations/useMutationPatchEntity.ts

import { useMutation } from '@tanstack/react-query';
import { AxiosInstance } from 'axios';

/**
 * useQuery<MethodEntity>.ts
 *
 * <MethodEntity> | <methodEntity>
 * Choose a method from API: GET, POST, PUT, DELETE, PATCH, ... that represents action.
 * Choose an entity name: User, OrderList, Orders, InstitutionEnum, ... that represents a resource.
 * These names are used to create a name <MethodEntity> for the API endpoints: GetInstitutionEnum, PostUser, ...
 */

/**
 * <MethodEntity>RequestStaticData (optional)
 * Request static data - used for a request creation. Data not changing with each request (e.g. ID of the entity).
 */
export type PatchEntityRequestStaticData = {
  id: number;
};

/**
 * <MethodEntity>RequestData (optional)
 * Request dynamic data - used for a request call (e.g. user input).
 */
export type PatchEntityRequestDynamicData = {
  name: string;
};

/**
 * <MethodEntity>ResponseData (required)
 * Response data - returned from the server (inside body).
 * This type should not be declared anywhere else and should not use any external type declarations (out of this file).
 * This file is a single source of truth that defines a behavior of the endpoint.
 */
export type PatchEntityResponseData = {
  id: number;
  name: string;
};

/**
 * <methodEntity>Request (required)
 * Request - stand-alone fetch.
 * @param axios - Axios client
 * @param requestStaticData - Request static data
 * @param () => requestDynamicData - Request dynamic data
 */
export const patchEntityRequest =
  ({
    axios,
    requestStaticData,
  }: {
    axios: AxiosInstance;
    requestStaticData: PatchEntityRequestStaticData;
  }) =>
  (requestDynamicData: PatchEntityRequestDynamicData) =>
    axios
      .patch<PatchEntityResponseData>(`/api/mock/users/${requestStaticData.id}`, requestDynamicData)
      .then((res) => res.data);

/**
 *
 * <methodEntity>MutationStaticData (optional)
 * Mutation static data - used for mutation and request creation.
 */
export type PatchEntityMutationRequestData = {
  requestStaticData: PatchEntityRequestStaticData;
};

/**
 * useMutation<MethodEntity> (required)
 * React hook - used for mutation creation.
 * @param axios - Axios client.
 * @param mutationStaticData - Static data used for mutation creation.
 */
export const useMutationPatchEntity = ({
  axios,
  mutationStaticData,
}: {
  axios: AxiosInstance;
  mutationStaticData: PatchEntityMutationRequestData;
}) =>
  useMutation({
    mutationFn: patchEntityRequest({
      axios,
      requestStaticData: mutationStaticData.requestStaticData,
    }),
  });

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 useQueryUserProjects to make it universal because of the possibility of some public projects, etc. We need static data with a project type, a response type is generated from OpenAPI definition, and there is no need to configure fetch request with each hook call (only request itself, because of server calls):

// ~/src/configs/tsq.ts

import type { Query, QueryKey } from '@tanstack/react-query';

type Filters = { [key: string]: string | number | undefined };
type QueryKeyList = { [key: string]: (...args: never[]) => QueryKey };
type QueryKeyPredicate = (query: Query) => boolean;
type QueryKeyInvalidationList = {
  [key: string]: QueryKey | QueryKeyPredicate | ((...args: never[]) => QueryKeyPredicate);
};

export const QK = {
  // ...
  getUserProjects:  (filters: Filters) => ['users', 'projects', filters],
} as const satisfies QueryKeyList;

// optional
export const QK_INVALIDATION = {
  prefixUsers: ['users'],
  prefixUserProjects: ['users', 'projects'],
} as const satisfies QueryKeyInvalidationList;
// ~/src/queries/useQueryGetUserProjects.ts

import { keepPreviousData, queryOptions } from '@tanstack/react-query';
import { AxiosInstance } from 'axios';
import ApiTypes from '~/swagger-types';
import { QK } from '~/src/configs/tsq'

export type GetUserProjectsRequestData = {
    type?: string;
};

export type GetUserProjectsResponseData =
    ApiTypes.paths['/api/v2/users/project']['get']['responses']['200']['content']['application/json'];

export const getUserProjectsRequest =
    ({ axios, requestData }: { axios: AxiosInstance; requestData: GetUserProjectsRequestData }) =>
    () =>
        axios
            .get<GetUserProjectsResponseData>('/api/v2/users/project', {
                params: {
                    type: requestData.type,
                },
            })
            .then((res) => res.data);

export type GetUserProjectsQueryRequestData = {
    requestData: GetUserProjectsRequestData;
};

export const getUserProjectsQueryOptions = <T>({
  axios,
  queryRequestData,
  select,
}: {
  axios: AxiosInstance;
  queryRequestData: GetUserProjectsQueryRequestData;
  select?: (data: GetUserProjectsResponseData) => T;
}) =>
  queryOptions({
    queryKey: QK.getUserProjects({ type: queryRequestData.requestData.type }), 
    queryFn: getUserProjectsRequest({ axios, requestData: queryRequestData.requestData }),
    placeholderData: keepPreviousData,
    select,
  });
// Component.tsx

import axiosClient = '~/axios';
import { getUserProjectsQueryOptions } from '~/src/queries/useQueryGetUserProjects.ts';

export const Component = () => {
  const { data: dataGetUserProjects, isFetching: isFetchingGetUserProjects } = useQuery(
    getUserProjectsQueryOptions({
      axios: axiosClient,
      queryRequestData: {
        requestData: {
          type: 'private'
        },
      },
    }),
  );

  return <>...</>;
};

The original interface is preserved - it has consistency, and it is easy to extend. At first, it may feel like there 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 of calling it.