Skip to content

Usage with React Query

The problem of “where to put the keys”

Section titled “The problem of “where to put the keys””

If the project already has a division into entities, and each request corresponds to a single entity, the purest division will be by entity. In this case, we suggest using the following structure:

  • Directorysrc/
    • Directoryapp/
    • Directorypages/
    • Directoryentities/
      • Directory{entity}/
        • Directoryapi/
          • {entity}.query Query-factory where are the keys and functions
          • get-{entity} Entity getter function
          • create-{entity} Entity creation function
          • update-{entity} Entity update function
          • delete-{entity} Entity delete function
    • Directoryfeatures/
    • Directorywidgets/
    • Directoryshared/

If there are connections between the entities (for example, the Country entity has a field-list of City entities), then you can use the public API for cross-imports or consider the alternative solution below.

Alternative solution — keep it in shared

Section titled “Alternative solution — keep it in shared”

In cases where entity separation is not appropriate, the following structure can be considered:

  • Directorysrc/
    • Directoryshared/
      • Directoryapi/
        • Directoryqueries Query-factories
          • document.ts
          • background-jobs.ts
        • index.ts

Then in @/shared/api/index.ts:

@/shared/api/index.ts
export { documentQueries } from "./queries/document";

The problem of “Where to insert mutations?”

Section titled “The problem of “Where to insert mutations?””

It is not recommended to mix mutations with queries. There are two options:

1. Define a custom hook in the api segment near the place of use

Section titled “1. Define a custom hook in the api segment near the place of use”
@/features/update-post/api/use-update-title.ts
export const useUpdateTitle = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, newTitle }) =>
apiClient
.patch(`/posts/${id}`, { title: newTitle })
.then((data) => console.log(data)),
onSuccess: (newPost) => {
queryClient.setQueryData(postsQueries.ids(id), newPost);
},
});
};

2. Define a mutation function somewhere else (Shared or Entities) and use useMutation directly in the component

Section titled “2. Define a mutation function somewhere else (Shared or Entities) and use useMutation directly in the component”
const { mutateAsync, isPending } = useMutation({
mutationFn: postApi.createPost,
});
@/pages/post-create/ui/post-create-page.tsx
export const CreatePost = () => {
const { classes } = useStyles();
const [title, setTitle] = useState("");
const { mutate, isPending } = useMutation({
mutationFn: postApi.createPost,
});
const handleChange = (e: ChangeEvent<HTMLInputElement>) =>
setTitle(e.target.value);
const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
mutate({ title, userId: DEFAULT_USER_ID });
};
return (
<form className={classes.create_form} onSubmit={handleSubmit}>
<TextField onChange={handleChange} value={title} />
<LoadingButton type="submit" variant="contained" loading={isPending}>
Create
</LoadingButton>
</form>
);
};

A query factory is an object where the key values are functions that return a list of query keys. Here’s how to use it:

const keyFactory = {
all: () => ["entity"],
lists: () => [...postQueries.all(), "list"],
};
@/entities/post/api/post.queries.ts
import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { getPosts } from "./get-posts";
import { getDetailPost } from "./get-detail-post";
import { PostDetailQuery } from "./query/post.query";
export const postQueries = {
all: () => ["posts"],
lists: () => [...postQueries.all(), "list"],
list: (page: number, limit: number) =>
queryOptions({
queryKey: [...postQueries.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: keepPreviousData,
}),
details: () => [...postQueries.all(), "detail"],
detail: (query?: PostDetailQuery) =>
queryOptions({
queryKey: [...postQueries.details(), query?.id],
queryFn: () => getDetailPost({ id: query?.id }),
staleTime: 5000,
}),
};

2. Using Query Factory in application code

Section titled “2. Using Query Factory in application code”
import { useParams } from "react-router-dom";
import { postApi } from "@/entities/post";
import { useQuery } from "@tanstack/react-query";
type Params = {
postId: string;
};
export const PostPage = () => {
const { postId } = useParams<Params>();
const id = parseInt(postId || "");
const {
data: post,
error,
isLoading,
isError,
} = useQuery(postApi.postQueries.detail({ id }));
if (isLoading) {
return <div>Loading...</div>;
}
if (isError || !post) {
return <>{error?.message}</>;
}
return (
<div>
<p>Post id: {post.id}</p>
<div>
<h1>{post.title}</h1>
<div>
<p>{post.body}</p>
</div>
</div>
<div>Owner: {post.userId}</div>
</div>
);
};
  • Request structuring: A factory allows you to organize all API requests in one place, making your code more readable and maintainable.
  • Convenient access to queries and keys: The factory provides convenient methods for accessing different types of queries and their keys.
  • Query Refetching Ability: The factory allows easy refetching without the need to change query keys in different parts of the application.

In this section, we’ll look at an example of the getPosts function, which makes an API request to retrieve post entities using pagination.

The getPosts function is located in the get-posts.ts file, which is located in the api segment

@/pages/post-feed/api/get-posts.ts
import { apiClient } from "@/shared/api/base";
import { PostWithPaginationDto } from "./dto/post-with-pagination.dto";
import { PostQuery } from "./query/post.query";
import { mapPost } from "./mapper/map-post";
import { PostWithPagination } from "../model/post-with-pagination";
const calculatePostPage = (totalCount: number, limit: number) =>
Math.floor(totalCount / limit);
export const getPosts = async (
page: number,
limit: number,
): Promise<PostWithPagination> => {
const skip = page * limit;
const query: PostQuery = { skip, limit };
const result = await apiClient.get<PostWithPaginationDto>("/posts", query);
return {
posts: result.posts.map((post) => mapPost(post)),
limit: result.limit,
skip: result.skip,
total: result.total,
totalPages: calculatePostPage(result.total, limit),
};
};

The postQueries query factory defines various query options for working with posts, including requesting a list of posts with a specific page and limit.

import { keepPreviousData, queryOptions } from "@tanstack/react-query";
import { getPosts } from "./get-posts";
export const postQueries = {
all: () => ["posts"],
lists: () => [...postQueries.all(), "list"],
list: (page: number, limit: number) =>
queryOptions({
queryKey: [...postQueries.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: keepPreviousData,
}),
};
@/pages/home/ui/index.tsx
export const HomePage = () => {
const itemsOnScreen = DEFAULT_ITEMS_ON_SCREEN;
const [page, setPage] = usePageParam(DEFAULT_PAGE);
const { data, isFetching, isLoading } = useQuery(
postApi.postQueries.list(page, itemsOnScreen),
);
return (
<>
<Pagination
onChange={(_, page) => setPage(page)}
page={page}
count={data?.totalPages}
variant="outlined"
color="primary"
/>
<Posts posts={data?.posts} />
</>
);
};

In this guide, we will look at how to organize a QueryProvider.

The file query-provider.tsx is located at the path @/app/providers/query-provider.tsx.

@/app/providers/query-provider.tsx
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { ReactNode } from "react";
type Props = {
children: ReactNode;
client: QueryClient;
};
export const QueryProvider = ({ client, children }: Props) => {
return (
<QueryClientProvider client={client}>
{children}
<ReactQueryDevtools />
</QueryClientProvider>
);
};

QueryClient is an instance used to manage API requests. The query-client.ts file is located at @/shared/api/query-client.ts. QueryClient is created with certain settings for query caching.

@/shared/api/query-client.ts
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
});

There are tools that can generate API code for you, but they are less flexible than the manual approach described above. If your Swagger file is well-structured, and you’re using one of these tools, it might make sense to generate all the code in the @/shared/api directory.

Using a custom API client class in the shared layer, you can standardize the configuration and work with the API in the project. This allows you to manage logging, headers and data exchange format (such as JSON or XML) from one place. This approach makes it easier to maintain and develop the project because it simplifies changes and updates to interactions with the API.

@/shared/api/api-client.ts
import { API_URL } from "@/shared/config";
export class ApiClient {
private baseUrl: string;
constructor(url: string) {
this.baseUrl = url;
}
async handleResponse<TResult>(response: Response): Promise<TResult> {
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
try {
return await response.json();
} catch (error) {
throw new Error("Error parsing JSON response");
}
}
public async get<TResult = unknown>(
endpoint: string,
queryParams?: Record<string, string | number>,
): Promise<TResult> {
const url = new URL(endpoint, this.baseUrl);
if (queryParams) {
Object.entries(queryParams).forEach(([key, value]) => {
url.searchParams.append(key, value.toString());
});
}
const response = await fetch(url.toString(), {
method: "GET",
headers: {
"Content-Type": "application/json",
},
});
return this.handleResponse<TResult>(response);
}
public async post<TResult = unknown, TData = Record<string, unknown>>(
endpoint: string,
body: TData,
): Promise<TResult> {
const response = await fetch(`${this.baseUrl}${endpoint}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(body),
});
return this.handleResponse<TResult>(response);
}
}
export const apiClient = new ApiClient(API_URL);