Skip to main content

Usage with React Query

The problem of β€œwhere to put the keys”​

Solution β€” break down by entities​

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:

└── src/                                        #
β”œβ”€β”€ app/ #
| ... #
β”œβ”€β”€ pages/ #
| ... #
β”œβ”€β”€ entities/ #
| β”œβ”€β”€ {entity}/ #
| ... └── api/ #
| β”œβ”€β”€ `{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
| ... #
| #
β”œβ”€β”€ features/ #
| ... #
β”œβ”€β”€ widgets/ #
| ... #
└── shared/ #
... #

If there are connections between the entities (for example, the Country entity has a field-list of City entities), then you can use an experimental approach to organized cross-imports via @x-notation or consider the alternative solution below.

Alternative solution β€” keep it in shared​

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

└── src/                                        #
... #
└── shared/ #
β”œβ”€β”€ api/ #
... β”œβ”€β”€ `queries` # 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?”​

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​

@/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​

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>
);
};

Organization of requests​

Query factory​

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"],
};
info

queryOptions is a built-in utility in react-query@v5 (optional)

queryOptions({
queryKey,
...options,
});

For greater type safety, further compatibility with future versions of react-query, and easy access to functions and query keys, you can use the built-in queryOptions function from β€œ@tanstack/react-query” (More details here).

1. Creating a Query Factory​

@/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​

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>
);
};

Benefits of using a Query Factory​

  • 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.

Pagination​

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

1. Creating a function getPosts​

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),
};
};

2. Query factory for pagination​

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,
}),
};

3. Use in application code​

@/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} />
</>
);
};
note

The example is simplified, the full version is available on GitHub

QueryProvider for managing queries​

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

1. Creating 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>
);
};

2. Creating a QueryClient​

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,
},
},
});

Code generation​

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.

Additional advice for organizing RQ​

API Client​

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);

See also​