Использование с React Query
Проблема «куда положить ключи»
Заголовок раздела «Проблема «куда положить ключи»»Решение - разбить по сущностям
Заголовок раздела «Решение - разбить по сущностям»Если в проекте уже присутствует разделение на сущности, и каждый запрос соответствует одной сущности, наиболее чистым будет разделение по сущностям. В таком случае, предлагаем использовать следующую структуру:
Директорияsrc/
Директорияapp/
- …
Директорияpages/
- …
Директорияentities/
Директория{entity}/
Директорияapi/
- {entity}.query (Фабрика запросов, где определены ключи и функции)
- get-{entity} (Функция получения сущности)
- create-{entity} (Функция создания сущности)
- update-{entity} (Функция обновления объекта)
- delete-{entity} (Функция удаления объекта)
Директорияfeatures/
- …
Директорияwidgets/
- …
Директорияshared/
- …
Если среди сущностей есть связи (например, у сущности Страна есть поле-список сущностей Город), то можно воспользоваться публичным API для кросс-импортов или рассмотреть альтернативное решение ниже.
Альтернативное решение — хранить запросы в общем доступе.
Заголовок раздела «Альтернативное решение — хранить запросы в общем доступе.»В случаях, когда не подходит разделение по сущностям, можно рассмотреть следующую структуру:
Директорияsrc/
Директорияshared/
Директорияapi/
Директорияqueries (Query-factories)
- document.ts
- background-jobs.ts
- index.ts
Затем в @/shared/api/index.ts:
export { documentQueries } from "./queries/document";Проблема «Куда мутации?»
Заголовок раздела «Проблема «Куда мутации?»»Мутации не рекомендуется смешивать с запросами. Возможны два варианта:
1. Определить кастомный хук в сегменте api рядом с местом использования
Заголовок раздела «1. Определить кастомный хук в сегменте api рядом с местом использования»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. Определить функцию мутации в другом месте (Shared или Entities) и использовать useMutation напрямую в компоненте
Заголовок раздела «2. Определить функцию мутации в другом месте (Shared или Entities) и использовать useMutation напрямую в компоненте»const { mutateAsync, isPending } = useMutation({ mutationFn: postApi.createPost,});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> );};Организация запросов
Заголовок раздела «Организация запросов»Фабрика запросов
Заголовок раздела «Фабрика запросов»В этом гайде рассмотрим, как использовать фабрику запросов (объект, где значениями ключа - являются функции, возвращающие список ключей запроса)
const keyFactory = { all: () => ["entity"], lists: () => [...postQueries.all(), "list"],};1. Создание Фабрики запросов
Заголовок раздела «1. Создание Фабрики запросов»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. Применение Фабрики запросов в коде приложения
Заголовок раздела «2. Применение Фабрики запросов в коде приложения»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> );};Преимущества использования Фабрики запросов
Заголовок раздела «Преимущества использования Фабрики запросов»- Структурирование запросов: Фабрика позволяет организовать все запросы к API в одном месте, что делает код более читаемым и поддерживаемым.
- Удобный доступ к запросам и ключам: Фабрика предоставляет удобные методы для доступа к различным типам запросов и их ключам.
- Возможность рефетчинга запросов: Фабрика обеспечивает возможность легкой рефетчинга без необходимости изменения ключей запросов в разных частях приложения.
Пагинация
Заголовок раздела «Пагинация»В этом разделе рассмотрим пример функции getPosts, которая выполняет запрос к API для получения сущностей постов с применением пагинации.
1. Создание функции getPosts
Заголовок раздела «1. Создание функции getPosts»Функция getPosts находится в файле get-posts.ts, который находится в сегменте API.
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. Фабрика запросов для пагинации
Заголовок раздела «2. Фабрика запросов для пагинации»Фабрика запросов postQueries определяет различные варианты запросов для работы с постами,
включая запрос списка постов с заранее определенной страницей и лимитом.
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. Использование в коде приложения
Заголовок раздела «3. Использование в коде приложения»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} /> </> );};QueryProvider для управления запросами
Заголовок раздела «QueryProvider для управления запросами»В этом гайде рассмотрим, как организовать QueryProvider.
1. Создание QueryProvider
Заголовок раздела «1. Создание QueryProvider»Файл 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. Создание QueryClient
Заголовок раздела «2. Создание QueryClient»QueryClient представляет собой экземпляр, используемый для управления запросами к API.
Файл query-client.ts расположен по пути @/shared/api/query-client.ts.
QueryClient создается с определенными настройками для кэширования запросов.
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({ defaultOptions: { queries: { staleTime: 5 * 60 * 1000, gcTime: 5 * 60 * 1000, }, },});Кодогенерация
Заголовок раздела «Кодогенерация»Существуют инструменты для автоматической генерации кода, которые менее гибкие, по сравнению с теми, что можно настроить, как описано выше. Если ваш Swagger-файл хорошо структурирован, и вы используете одно из таких инструментов, то возможно имеет смысл сгенерировать весь код в каталоге @/shared/api.
Дополнительный совет по организации RQ
Заголовок раздела «Дополнительный совет по организации RQ»API-Клиент
Заголовок раздела «API-Клиент»Используя собственный класс клиента API в общем слое shared, можно стандартизировать настройку и работу с API в проекте. Это позволяет управлять логированием, заголовками и форматом обмена данными (например, JSON или XML) из одного места. Такой подход облегчает поддержку и развитие проекта, поскольку упрощает изменения и обновления взаимодействия с API.
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);