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

Использование с 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,
});

1. Создание Фабрики запросов

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. Применение Фабрики запросов в коде приложения

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 используется для реализации паттернов «загрузить ещё» или бесконечной прокрутки.

1. Фабрика запросов с infiniteQueryOptions

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

2. Использование в компоненте

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

Suspense mode

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

1. Фабрика запросов остаётся прежней

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

2. Использование в компоненте

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

3. Обёртка в app слое

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

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

1. Хранение ключей мутаций

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

src/shared/api/post/post.queries.ts
export const POST_MUTATIONS = {
updateTitle: () => ['post', 'update-title'],
create: () => ['post', 'create'],
};

2. Именование мутации через mutationKey

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

3. Чтение состояния в другом компоненте

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

Организация QueryProvider

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

Полезные ссылки