Перейти к содержимому

Использование с TanStack Query

  • Директорияsrc/
    • Директорияapp/
    • Директорияpages/
    • Директорияwidgets/
    • Директорияfeatures/
    • Директорияentities/
    • Директорияshared/
      • Директорияapi/
        • Директорияqueries/ Фабрики запросов
          • example.ts
          • another-example.ts
src/shared/api/index.ts
export { exampleQueries } from './queries/example';

Мутации не рекомендуется смешивать с запросами. Исходя из этого можно предложить несколько вариантов:

src/pages/example/api/use-update-example.ts
export const useUpdateExample = () => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ id, newTitle }) => {
const { data } = await apiClient.patch(`/posts/${ id }`, { title: newTitle });
return data;
},
onSuccess: newPost => {
queryClient.setQueryData(postsQueries.ids(id), newPost);
},
});
};

В этом гайде рассмотрим, как использовать фабрику запросов (объект, где значениями ключа - являются функции, возвращающие список ключей запроса)

const keyFactory = {
all: () => ["entity"],
lists: () => [...postQueries.all(), "list"],
};

queryOptions - встроенная утилита react-query@v5

Одним из лучших способов совместного использования queryKey и queryFn в нескольких местах — это использование вспомогательной функции queryOptions (подробнее здесь)

import { queryOptions } from '@tanstack/react-query';
const groupOptions = (id: number) => queryOptions({
queryKey: ['groups', id],
queryFn: () => fetchGroups(id),
gcTime: 5 * 1000,
});
src/shared/api/post/post.queries.ts
import { queryOptions } from '@tanstack/react-query';
import { getPosts } from './get-posts';
import { getDetailPost, type DetailPostQuery } from './get-detail-post';
export const POST_QUERIES = {
all: () => ['posts'],
lists: () => [...POST_QUERIES.all(), 'list'],
list: (page: number, limit: number) => queryOptions({
queryKey: [...POST_QUERIES.lists(), page, limit],
queryFn: () => getPosts(page, limit),
placeholderData: prev => prev,
}),
details: () => [...POST_QUERIES.all(), 'detail'],
detail: (query?: DetailPostQuery) => queryOptions({
queryKey: [...POST_QUERIES.details(), query?.id],
queryFn: () => getDetailPost({ id: query?.id }),
}),
};

2. Применение Фабрики запросов в коде приложения

Заголовок раздела «2. Применение Фабрики запросов в коде приложения»
src/pages/post/ui/post.tsx
import { useParams } from 'react-router';
import { postApi } from '@/shared/api/post';
import { useQuery } from '@tanstack/react-query';
interface Params {
postId: string;
}
export const Post = () => {
const { postId } = useParams<IParams>();
const {
data: post,
error,
isLoading,
isError
} = useQuery(postApi.postQueries.detail({ id: parseInt(postId ?? '', 10) }));
if (isLoading) {
return (
<div>Loading...</div>
);
}
if (isError || !post) {
return (
<div>{ error?.message }</div>
);
}
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 в одном месте, что делает код более читаемым и поддерживаемым.
  • Удобный доступ к запросам и ключам: Фабрика предоставляет удобные методы для доступа к различным типам запросов и их ключам.
  • Возможность рефетчинга запросов: Фабрика обеспечивает возможность легкой рефетчинга без необходимости изменения ключей запросов в разных частях приложения.

Для пагинации используется та же фабрика запросов из раздела «Организация запросов» с добавлением placeholderData

src/pages/home/ui/home.tsx
export const Home = () => {
const [page, setPage] = usePageParam(DEFAULT_PAGE);
const { data, isFetching, isLoading } = useQuery(postApi.POST_QUERIES.list(page, DEFAULT_ITEMS_ON_SCREEN));
return (
<>
<Pagination
onChange={ (_, page) => setPage(page) }
page={ page }
count={ data?.totalPages }
variant="outlined"
color="primary"
/>
<Posts posts={ data?.posts } />
</>
);
};

useInfiniteQuery используется для реализации паттернов «загрузить ещё» или бесконечной прокрутки.

src/shared/api/post/post.queries.ts
import { infiniteQueryOptions } from '@tanstack/react-query';
import { getPosts } from './get-posts';
export const POST_QUERIES = {
all: () => ['posts'],
lists: () => [...POST_QUERIES.all(), 'list'],
infinite: (limit: number) => infiniteQueryOptions({
queryKey: [...POST_QUERIES.lists(), 'infinite', limit],
queryFn: ({ pageParam }) => getPosts(pageParam, limit),
initialPageParam: 0,
getNextPageParam: lastPage =>
lastPage.skip + lastPage.limit < lastPage.total
? lastPage.skip / lastPage.limit + 1
: undefined,
}),
};
src/pages/post-feed/ui/post-feed.tsx
import { useInfiniteQuery } from '@tanstack/react-query';
import { postApi } from '@/shared/api/post';
export const PostFeed = () => {
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
useInfiniteQuery(postApi.POST_QUERIES.infinite(10));
const posts = data?.pages.flatMap(page => page.posts) ?? [];
return (
<>
<Posts posts={ posts } />
{ hasNextPage && (
<button onClick={ () => fetchNextPage() } disabled={ isFetchingNextPage }>
{ isFetchingNextPage ? 'Загрузка...' : 'Загрузить ещё' }
</button>
) }
</>
);
};

useSuspenseQuery позволяет использовать React Suspense для обработки состояния загрузки, убирая необходимость проверять isLoading вручную.

queryOptions и useSuspenseQuery совместимы — менять фабрику не нужно.

src/pages/post/ui/post.tsx
import { useSuspenseQuery } from '@tanstack/react-query';
import { postApi } from '@/shared/api/post';
interface PostProps {
id: number;
}
export const Post = ({ id }: PostProps) => {
const { data: post } = useSuspenseQuery(postApi.POST_QUERIES.detail({ id }));
return (
<div>
<h1>{ post.title }</h1>
<p>{ post.body }</p>
</div>
);
};
src/app/providers/suspense-provider.tsx
import { Suspense, type ReactNode } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
interface SuspenseProviderProps {
children: ReactNode;
}
export const SuspenseProvider = ({ children }: SuspenseProviderProps) => (
<ErrorBoundary fallback={ <div>Что-то пошло не так</div> }>
<Suspense fallback={ <div>Загрузка...</div> }>
{ children }
</Suspense>
</ErrorBoundary>
);

useMutationState позволяет читать состояние мутаций из любого компонента без передачи пропсов — удобно для глобальных индикаторов загрузки или отображения статуса операции.

По аналогии с фабрикой запросов, ключи мутаций стоит хранить в одном месте рядом с фабрикой:

src/shared/api/post/post.queries.ts
export const POST_MUTATIONS = {
updateTitle: () => ['post', 'update-title'],
create: () => ['post', 'create'],
};
src/features/update-post/api/use-update-post-title.ts
import { POST_MUTATIONS } from '@/shared/api/post';
interface UpdatePostTitle {
id: number;
newTitle: string;
}
export const useUpdatePostTitle = () =>
useMutation({
mutationKey: POST_MUTATIONS.updateTitle(),
mutationFn: ({ id, newTitle }: UpdatePostTitle) =>
apiClient.patch(`/posts/${id}`, { title: newTitle }),
});
src/features/update-post/ui/save-indicator.tsx
import { useMutationState } from '@tanstack/react-query';
import { POST_MUTATIONS } from '@/shared/api/post';
export const SaveIndicator = () => {
const isPending = useMutationState({
filters: { mutationKey: POST_MUTATIONS.updateTitle(), status: 'pending' },
select: mutation => mutation.state.status,
}).length > 0;
return isPending && (
<span>Сохранение...</span>
);
};
@/app/providers/query-provider.tsx
import { type ReactNode } from 'react';
import { QueryClient, QueryClientProvider, MutationCache, QueryCache } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
import { toast } from 'sonner';
interface QueryProviderProps {
children: ReactNode;
client: QueryClient;
}
const queryClient = new QueryClient({
queryCache: new QueryCache({
onError: error => {
toast.error(error.message);
},
}),
mutationCache: new MutationCache({
onError: error => {
toast.error(error.message);
},
}),
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
gcTime: 5 * 60 * 1000,
},
},
});
export const QueryProvider = ({ client, children }: QueryProviderProps) => {
return (
<QueryClientProvider client={ client }>
{ children }
<ReactQueryDevtools />
</QueryClientProvider>
);
};

Существуют инструменты для автоматической генерации кода, которые менее гибкие, по сравнению с теми, что можно настроить, как описано выше. Если ваш Swagger-файл хорошо структурирован, и вы используете одно из таких инструментов, то возможно имеет смысл сгенерировать весь код в каталоге @/shared/api.

Дополнительный совет по взаимодействию с запросами

Заголовок раздела «Дополнительный совет по взаимодействию с запросами»

Используя собственный класс клиента API в общем слое shared, можно стандартизировать настройку и работу с API в проекте. Это позволяет управлять логированием, заголовками и форматом обмена данными (например, JSON или XML) из одного места. Такой подход облегчает поддержку и развитие проекта, поскольку упрощает изменения и обновления взаимодействия с API.

@/shared/api/api-client.ts
import { API_URL } from "@/shared/config";
export class ApiClient {
#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);