diff --git a/src/content/docs/docs/guides/tech/with-react-query.mdx b/src/content/docs/docs/guides/tech/with-react-query.mdx index e1d0d24318..8f8e5dfbb9 100644 --- a/src/content/docs/docs/guides/tech/with-react-query.mdx +++ b/src/content/docs/docs/guides/tech/with-react-query.mdx @@ -1,431 +1,543 @@ --- -title: Usage with React Query +title: Usage with TanStack Query sidebar: - order: 10 + order: 2 --- -import { FileTree, Aside } from '@astrojs/starlight/components'; - -## 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 the [public API for cross-imports][public-api-for-cross-imports] 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`: - -```ts title="@/shared/api/index.ts" -export { documentQueries } from "./queries/document"; -``` +import { Tabs, TabItem, FileTree } from '@astrojs/starlight/components'; + +## Where to store keys \{#where-to-store-keys\} + + + + + + - src/ + - app/ + - pages/ + - widgets/ + - features/ + - entities/ + - shared/ + - api/ + - queries/ Query factories + - example.ts + - another-example.ts + + + ```ts title="src/shared/api/index.ts" + export { exampleQueries } from './queries/example'; + ``` + + + + + + If the number of endpoints is large enough and you also want to store them in `shared`, it is better to split everything by controllers and use a public API for each of them. + + + - src/ + - app/ + - pages/ + - widgets/ + - features/ + - entities/ + - shared/ + - api/ + - example/ + - index.ts + - example.query.ts Query factory with keys and functions for the example controller + - get-example.ts + - create-example.ts + - update-example.ts + - delete-example.ts + - another-example/ + - index.ts + - another-example.query.ts Query factory with keys and functions for the another-example controller + - get-another-example.ts + - create-another-example.ts + - update-another-example.ts + - delete-another-example.ts + + + ```ts title="src/shared/api/example/index.ts" + export { exampleQueries } from "./example.query"; + ``` + + + + + + *If the project already has a division into entities*, and each request corresponds to a single entity, + the cleanest approach is to split by entity. In this case, we suggest using the following structure: + + + - src/ + - app/ + - pages/ + - widgets/ + - features/ + - entities/ + - example/ + - api/ + - example.query.ts Query factory with keys and functions + - get-example.ts + - create-example.ts + - update-example.ts + - delete-example.ts + - shared/ + + + If there are connections between entities (for example, the Country entity has a field-list of City entities), you can use the [public API for cross-imports][public-api-for-cross-imports]. + + + + + +## Where to store mutations \{#where-to-store-mutations\} + +It is not recommended to mix mutations with queries. There are several options: + + + + + + ```tsx title="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); + }, + }); + }; + ``` + + + + + + ```tsx title="src/pages/example/ui/example.tsx" + export const Example = () => { + const [title, setTitle] = useState(''); + + const { mutate, isPending } = useMutation({ + mutationFn: mutations.createExample, + }); + + const handleChange = ({ target: { value } }: ChangeEvent) => { + setTitle(value); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + +
+ ); + }; + ``` -## 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 +## Organising queries \{#organizing-queries\} -```tsx title="@/features/update-post/api/use-update-title.ts" -export const useUpdateTitle = () => { - const queryClient = useQueryClient(); +### Query factory \{#query-factory\} - return useMutation({ - mutationFn: ({ id, newTitle }) => - apiClient - .patch(`/posts/${id}`, { title: newTitle }) - .then((data) => console.log(data)), +A query factory is an object where key values are functions that return a list of query keys. - onSuccess: (newPost) => { - queryClient.setQueryData(postsQueries.ids(id), newPost); - }, - }); +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...keyFactory.all(), "list"], }; ``` -### 2. Define a mutation function somewhere else (Shared or Entities) and use `useMutation` directly in the component +:::info + `queryOptions` — a built-in utility in react-query@v5 + +One of the best ways to share `queryKey` and `queryFn` in multiple places is to use the `queryOptions` helper function [(more details here)](https://tanstack.com/query/latest/docs/framework/react/guides/query-options). -```tsx -const { mutateAsync, isPending } = useMutation({ - mutationFn: postApi.createPost, +```ts +import { queryOptions } from '@tanstack/react-query'; + +const groupOptions = (id: number) => queryOptions({ + queryKey: ['groups', id], + queryFn: () => fetchGroups(id), + gcTime: 5 * 1000, }); ``` - -```tsx title="@/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) => - setTitle(e.target.value); - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - mutate({ title, userId: DEFAULT_USER_ID }); - }; - - return ( -
- - - Create - - - ); +::: + +### 1. Creating a query factory \{#creating-query-factory\} + +```tsx title="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 }), + }), }; ``` -## Organization of requests +### 2. Using a query factory in application code \{#using-query-factory\} -### Query factory +```tsx title="src/pages/post/ui/post.tsx" +import { useParams } from 'react-router'; +import { postApi } from '@/shared/api/post'; +import { useQuery } from '@tanstack/react-query'; -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: +interface Params { + postId: string; +} -```tsx -const keyFactory = { - all: () => ["entity"], - lists: () => [...postQueries.all(), "list"], +export const Post = () => { + const { postId } = useParams(); + + const { + data: post, + error, + isLoading, + isError + } = useQuery(postApi.POST_QUERIES.detail({ id: parseInt(postId ?? '', 10) })); + + if (isLoading) { + return ( +
Loading...
+ ); + } + + if (isError || !post) { + return ( +
{ error?.message }
+ ); + } + + return ( +
+

Post id: { post.id }

+
+

{ post.title }

+
+

{ post.body }

+
+
+
Owner: { post.userId }
+
+ ); }; ``` - +Pagination uses the same query factory from the [organising queries](#organizing-queries) section above, with the addition of `placeholderData` to prevent the UI from flickering when navigating between pages. -### 1. Creating a Query Factory +### Usage in a component \{#pagination-usage\} -```tsx title="@/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"; +```tsx title="src/pages/home/ui/home.tsx" +export const Home = () => { + const [page, setPage] = usePageParam(DEFAULT_PAGE); -export const postQueries = { - all: () => ["posts"], + const { data, isFetching, isLoading } = useQuery(postApi.POST_QUERIES.list(page, DEFAULT_ITEMS_ON_SCREEN)); - lists: () => [...postQueries.all(), "list"], - list: (page: number, limit: number) => - queryOptions({ - queryKey: [...postQueries.lists(), page, limit], - queryFn: () => getPosts(page, limit), - placeholderData: keepPreviousData, - }), + return ( + <> + setPage(page) } + page={ page } + count={ data?.totalPages } + variant="outlined" + color="primary" + /> + + + ); +}; +``` - details: () => [...postQueries.all(), "detail"], - detail: (query?: PostDetailQuery) => - queryOptions({ - queryKey: [...postQueries.details(), query?.id], - queryFn: () => getDetailPost({ id: query?.id }), - staleTime: 5000, +## Infinite scroll \{#infinite-scroll\} + +`useInfiniteQuery` is used to implement "load more" or infinite scroll patterns. + +### 1. Query factory with `infiniteQueryOptions` \{#infinite-query-factory\} + +```tsx title="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. Using Query Factory in application code -```tsx -import { useParams } from "react-router-dom"; -import { postApi } from "@/entities/post"; -import { useQuery } from "@tanstack/react-query"; +### 2. Usage in a component \{#infinite-query-usage\} -type Params = { - postId: string; -}; +```tsx title="src/pages/post-feed/ui/post-feed.tsx" +import { useInfiniteQuery } from '@tanstack/react-query'; +import { postApi } from '@/shared/api/post'; -export const PostPage = () => { - const { postId } = useParams(); - const id = parseInt(postId || ""); - const { - data: post, - error, - isLoading, - isError, - } = useQuery(postApi.postQueries.detail({ id })); - - if (isLoading) { - return
Loading...
; - } - - if (isError || !post) { - return <>{error?.message}; - } - - return ( -
-

Post id: {post.id}

-
-

{post.title}

-
-

{post.body}

-
-
-
Owner: {post.userId}
-
- ); +export const PostFeed = () => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery(postApi.POST_QUERIES.infinite(10)); + + const posts = data?.pages.flatMap((page) => page.posts) ?? []; + + return ( + <> + + { hasNextPage && ( + + ) } + + ); }; ``` -### 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. +## Suspense mode \{#suspense-mode\} -## Pagination +`useSuspenseQuery` allows you to use React Suspense for handling loading states, removing the need to check `isLoading` manually. -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. The query factory remains the same \{#suspense-query-factory\} -### 1. Creating a function `getPosts` -The getPosts function is located in the `get-posts.ts` file, which is located in the `api` segment +`queryOptions` and `useSuspenseQuery` are compatible — no changes to the factory are needed. -```tsx title="@/pages/post-feed/api/get-posts.ts" -import { apiClient } from "@/shared/api/base"; +### 2. Usage in a component \{#suspense-usage\} -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"; +```tsx title="src/pages/post/ui/post.tsx" +import { useSuspenseQuery } from '@tanstack/react-query'; +import { postApi } from '@/shared/api/post'; -const calculatePostPage = (totalCount: number, limit: number) => - Math.floor(totalCount / limit); +interface PostProps { + id: number; +} -export const getPosts = async ( - page: number, - limit: number, -): Promise => { - const skip = page * limit; - const query: PostQuery = { skip, limit }; - const result = await apiClient.get("/posts", query); +// isLoading is no longer needed — the component only renders when data is ready +export const Post = ({ id }: PostProps) => { + const { data: post } = useSuspenseQuery(postApi.POST_QUERIES.detail({ id })); - return { - posts: result.posts.map((post) => mapPost(post)), - limit: result.limit, - skip: result.skip, - total: result.total, - totalPages: calculatePostPage(result.total, limit), - }; + return ( +
+

{ post.title }

+

{ post.body }

+
+ ); }; ``` -### 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. - -```tsx -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. Wrapper in the `app` layer \{#suspense-app-wrapper\} + +```tsx title="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) => ( + Something went wrong }> + Loading... }> + { children } + + +); ``` +## `useMutationState` \{#use-mutation-state\} + +`useMutationState` allows you to read the state of mutations from any component without passing props — useful for global loading indicators or displaying the status of an operation. + +### 1. Storing mutation keys \{#storing-mutation-keys\} -### 3. Use in application code - -```tsx title="@/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 ( - <> - setPage(page)} - page={page} - count={data?.totalPages} - variant="outlined" - color="primary" - /> - - - ); +By analogy with the query factory, mutation keys should be stored in one place alongside the factory: + +```ts title="src/shared/api/post/post.queries.ts" +export const POST_MUTATIONS = { + updateTitle: () => ['post', 'update-title'], + create: () => ['post', 'create'], }; ``` - -## `QueryProvider` for managing queries -In this guide, we will look at how to organize a `QueryProvider`. +### 2. Naming mutations via `mutationKey` \{#naming-mutations\} -### 1. Creating a `QueryProvider` -The file `query-provider.tsx` is located at the path `@/app/providers/query-provider.tsx`. +```tsx title="src/features/update-post/api/use-update-post-title.ts" +import { POST_MUTATIONS } from '@/shared/api/post'; -```tsx title="@/app/providers/query-provider.tsx" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { ReactNode } from "react"; +interface UpdatePostTitle { + id: number; + newTitle: string; +} -type Props = { - children: ReactNode; - client: QueryClient; -}; +export const useUpdatePostTitle = () => + useMutation({ + mutationKey: POST_MUTATIONS.updateTitle(), + mutationFn: ({ id, newTitle }: UpdatePostTitle) => + apiClient.patch(`/posts/${id}`, { title: newTitle }), + }); +``` + +### 3. Reading state in another component \{#reading-mutation-state\} -export const QueryProvider = ({ client, children }: Props) => { - return ( - - {children} - - - ); +```tsx title="src/widgets/save-indicator/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 && ( + Saving... + ); }; ``` -### 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. +## Organising `QueryProvider` \{#query-provider\} -```tsx title="@/shared/api/query-client.ts" -import { QueryClient } from "@tanstack/react-query"; +```tsx title="src/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'; -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - gcTime: 5 * 60 * 1000, +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, + }, }, - }, }); -``` -## Code generation +export const QueryProvider = ({ client, children }: QueryProviderProps) => { + return ( + + { children } + + + ); +}; +``` -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. +## Code generation \{#code-generation\} +There are tools for automatic code generation that are less flexible than the manual approach described above. If your Swagger file is well-structured and you are using one of these tools, it may make sense to generate all the code in the `@/shared/api` directory. -## Additional advice for organizing RQ -### API Client +## Additional advice on API interaction \{#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. +By using a custom API client class in the `shared` layer, you can standardise the configuration and work with the API across the project. This allows you to manage logging, headers, and the data exchange format (such as JSON or XML) from one place. This approach simplifies maintenance and development by making changes and updates to API interactions easier. -```tsx title="@/shared/api/api-client.ts" +```tsx title="src/shared/api/api-client.ts" import { API_URL } from "@/shared/config"; export class ApiClient { - private baseUrl: string; + #baseUrl: string; - constructor(url: string) { - this.baseUrl = url; - } - - async handleResponse(response: Response): Promise { - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); + constructor(url: string) { + this.#baseUrl = url; } - try { - return await response.json(); - } catch (error) { - throw new Error("Error parsing JSON response"); + async handleResponse(response: Response): Promise { + 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( - endpoint: string, - queryParams?: Record, - ): Promise { - const url = new URL(endpoint, this.baseUrl); - - if (queryParams) { - Object.entries(queryParams).forEach(([key, value]) => { - url.searchParams.append(key, value.toString()); - }); + + public async get(endpoint: string, queryParams?: Record): Promise { + 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(response); } - const response = await fetch(url.toString(), { - method: "GET", - headers: { - "Content-Type": "application/json", - }, - }); - return this.handleResponse(response); - } - - public async post>( - endpoint: string, - body: TData, - ): Promise { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(body), - }); + public async post>(endpoint: string, body: TData): Promise { + const response = await fetch(`${ this.#baseUrl }${ endpoint }`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); - return this.handleResponse(response); - } + return this.handleResponse(response); + } } export const apiClient = new ApiClient(API_URL); diff --git a/src/content/docs/ru/docs/guides/tech/with-react-query.mdx b/src/content/docs/ru/docs/guides/tech/with-react-query.mdx index 1f274061ef..f52ee47d5d 100644 --- a/src/content/docs/ru/docs/guides/tech/with-react-query.mdx +++ b/src/content/docs/ru/docs/guides/tech/with-react-query.mdx @@ -1,420 +1,542 @@ --- -title: Использование с React Query +title: Использование с TanStack Query sidebar: - order: 10 + order: 2 --- -import { FileTree } from '@astrojs/starlight/components'; -import { Aside } from '@astrojs/starlight/components'; +import { Tabs, TabItem, FileTree } from '@astrojs/starlight/components'; + +## Где располагать ключи \{#where-to-store-keys\} + + + + + + - src/ + - app/ + - pages/ + - widgets/ + - features/ + - entities/ + - shared/ + - api/ + - queries/ Фабрики запросов + - example.ts + - another-example.ts + + + ```ts title="src/shared/api/index.ts" + export { exampleQueries } from './queries/example'; + ``` + + + + + + Если объём эндпоинтов оказывается достаточно большим и Вы также хотите хранить их в `shared`, то лучше разбить всё по контроллерам и использовать публичный API для каждого из них + + + - src/ + - app/ + - pages/ + - widgets/ + - features/ + - entities/ + - shared/ + - api/ + - example/ + - index.ts + - example.query.ts Фабрика запросов, где определены ключи и функции для контроллера example + - get-example.ts + - create-example.ts + - update-example.ts + - delete-example.ts + - another-example/ + - index.ts + - another-example.query.ts Фабрика запросов, где определены ключи и функции для контроллера another-example + - get-another-example.ts + - create-another-example.ts + - update-another-example.ts + - delete-another-example.ts + + + ```ts title="src/shared/api/example/index.ts" + export { exampleQueries } from "./example.query"; + ``` + + + + + + *Если в проекте уже присутствует разделение на сущности*, и каждый запрос соответствует одной сущности, + наиболее чистым будет разделение по сущностям. В таком случае, предлагаем использовать следующую структуру: + + + - src/ + - app/ + - pages/ + - widgets/ + - features/ + - entities/ + - example/ + - api/ + - example.query.ts Фабрика запросов, где определены ключи и функции + - get-example.ts + - create-example.ts + - update-example.ts + - delete-example.ts + - shared/ + + + Если среди сущностей есть связи (например, у сущности Страна есть поле-список сущностей Город), то можно воспользоваться [публичным API для кросс-импортов][public-api-for-cross-imports]. + + + + + +## Где располагать мутации \{#where-to-store-mutations\} + +Мутации не рекомендуется смешивать с запросами. Исходя из этого можно предложить несколько вариантов: + + + + + + ```tsx title="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); + }, + }); + }; + ``` + + + + + + ```tsx title="src/pages/example/ui/example.tsx" + export const Example = () => { + const [title, setTitle] = useState(''); + + const { mutate, isPending } = useMutation({ + mutationFn: mutations.createExample, + }); + + const handleChange = ({ target: { value } }: ChangeEvent) => { + setTitle(value); + }; + + const handleSubmit = (e: FormEvent) => { + e.preventDefault(); + mutate({ title, userId: DEFAULT_USER_ID }); + }; + + return ( +
+ + +
+ ); + }; + ``` +
-## Проблема «куда положить ключи» +
-### Решение - разбить по сущностям +## Организация запросов \{#organizing-queries\} -Если в проекте уже присутствует разделение на сущности, и каждый запрос соответствует одной сущности, -наиболее чистым будет разделение по сущностям. В таком случае, предлагаем использовать следующую структуру: +### Фабрика запросов \{#query-factory\} - -- src/ - - app/ - - pages/ - - entities/ - - \{entity\}/ - - api/ - - \{entity\}.query (Фабрика запросов, где определены ключи и функции) - - get-\{entity\} (Функция получения сущности) - - create-\{entity\} (Функция создания сущности) - - update-\{entity\} (Функция обновления объекта) - - delete-\{entity\} (Функция удаления объекта) - - features/ - - widgets/ - - shared/ - +В этом гайде рассмотрим, как использовать фабрику запросов (объект, где значениями ключа - являются функции, возвращающие список ключей запроса) -Если среди сущностей есть связи (например, у сущности Страна есть поле-список сущностей Город), то можно воспользоваться -[публичным API для кросс-импортов][public-api-for-cross-imports] или рассмотреть альтернативное решение ниже. +```ts +const keyFactory = { + all: () => ["entity"], + lists: () => [...postQueries.all(), "list"], +}; +``` +:::info + `queryOptions` - встроенная утилита react-query@v5 -### Альтернативное решение — хранить запросы в общем доступе. +Одним из лучших способов совместного использования `queryKey` и `queryFn` в нескольких местах — это использование вспомогательной функции `queryOptions` [(подробнее здесь)](https://tanstack.com/query/latest/docs/framework/react/guides/query-options) -В случаях, когда не подходит разделение по сущностям, можно рассмотреть следующую структуру: +```ts +import { queryOptions } from '@tanstack/react-query'; - -- src/ - - shared/ - - api/ - - queries (Query-factories) - - document.ts - - background-jobs.ts - - index.ts - +const groupOptions = (id: number) => queryOptions({ + queryKey: ['groups', id], + queryFn: () => fetchGroups(id), + gcTime: 5 * 1000, +}); +``` +::: + +### 1. Создание Фабрики запросов \{#creating-query-factory\} + +```tsx title="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 }), + }), +}; +``` -Затем в `@/shared/api/index.ts`: +### 2. Применение Фабрики запросов в коде приложения \{#using-query-factory\} -```ts title="@/shared/api/index.ts" -export { documentQueries } from "./queries/document"; -``` +```tsx title="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(); -### 1. Определить кастомный хук в сегменте api рядом с местом использования + const { + data: post, + error, + isLoading, + isError + } = useQuery(postApi.postQueries.detail({ id: parseInt(postId ?? '', 10) })); -```tsx title="@/features/update-post/api/use-update-title.ts" -export const useUpdateTitle = () => { - const queryClient = useQueryClient(); + if (isLoading) { + return ( +
Loading...
+ ); + } - return useMutation({ - mutationFn: ({ id, newTitle }) => - apiClient - .patch(`/posts/${id}`, { title: newTitle }) - .then((data) => console.log(data)), + if (isError || !post) { + return ( +
{ error?.message }
+ ); + } - onSuccess: (newPost) => { - queryClient.setQueryData(postsQueries.ids(id), newPost); - }, - }); + return ( +
+

Post id: { post.id }

+
+

{ post.title }

+
+

{ post.body }

+
+
+
Owner: { post.userId }
+
+ ); }; ``` -### 2. Определить функцию мутации в другом месте (Shared или Entities) и использовать `useMutation` напрямую в компоненте +### Преимущества использования Фабрики запросов \{#query-factory-benefits\} +- **Структурирование запросов:** Фабрика позволяет организовать все запросы к API в одном месте, что делает код более читаемым и поддерживаемым. +- **Удобный доступ к запросам и ключам:** Фабрика предоставляет удобные методы для доступа к различным типам запросов и их ключам. +- **Возможность рефетчинга запросов:** Фабрика обеспечивает возможность легкой рефетчинга без необходимости изменения ключей запросов в разных частях приложения. -```tsx -const { mutateAsync, isPending } = useMutation({ - mutationFn: postApi.createPost, -}); -``` +## Пагинация \{#pagination\} -```tsx title="@/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) => - setTitle(e.target.value); - const handleSubmit = (e: FormEvent) => { - e.preventDefault(); - mutate({ title, userId: DEFAULT_USER_ID }); - }; - - return ( -
- - - Create - - - ); -}; -``` +Для пагинации используется та же фабрика запросов из раздела [«Организация запросов»](#organizing-queries) с добавлением `placeholderData` -## Организация запросов +### Использование в компоненте \{#pagination-usage\} -### Фабрика запросов +```tsx title="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)); -```ts -const keyFactory = { - all: () => ["entity"], - lists: () => [...postQueries.all(), "list"], + return ( + <> + setPage(page) } + page={ page } + count={ data?.totalPages } + variant="outlined" + color="primary" + /> + + + ); }; ``` - +## Бесконечная прокрутка \{#infinite-scroll\} - +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, + }), +}; +``` -### 1. Создание Фабрики запросов +### 2. Использование в компоненте \{#infinite-query-usage\} -```tsx title="@/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"; +```tsx title="src/pages/post-feed/ui/post-feed.tsx" +import { useInfiniteQuery } from '@tanstack/react-query'; +import { postApi } from '@/shared/api/post'; -export const postQueries = { - all: () => ["posts"], +export const PostFeed = () => { + const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = + useInfiniteQuery(postApi.POST_QUERIES.infinite(10)); - lists: () => [...postQueries.all(), "list"], - list: (page: number, limit: number) => - queryOptions({ - queryKey: [...postQueries.lists(), page, limit], - queryFn: () => getPosts(page, limit), - placeholderData: keepPreviousData, - }), + const posts = data?.pages.flatMap(page => page.posts) ?? []; - details: () => [...postQueries.all(), "detail"], - detail: (query?: PostDetailQuery) => - queryOptions({ - queryKey: [...postQueries.details(), query?.id], - queryFn: () => getDetailPost({ id: query?.id }), - staleTime: 5000, - }), + return ( + <> + + { hasNextPage && ( + + ) } + + ); }; ``` -### 2. Применение Фабрики запросов в коде приложения -```tsx -import { useParams } from "react-router-dom"; -import { postApi } from "@/entities/post"; -import { useQuery } from "@tanstack/react-query"; +## Suspense mode \{#suspense-mode\} -type Params = { - postId: string; -}; +`useSuspenseQuery` позволяет использовать React Suspense для обработки состояния загрузки, убирая необходимость проверять `isLoading` вручную. -export const PostPage = () => { - const { postId } = useParams(); - const id = parseInt(postId || ""); - const { - data: post, - error, - isLoading, - isError, - } = useQuery(postApi.postQueries.detail({ id })); - - if (isLoading) { - return
Loading...
; - } +### 1. Фабрика запросов остаётся прежней \{#suspense-query-factory\} - if (isError || !post) { - return <>{error?.message}; - } +`queryOptions` и `useSuspenseQuery` совместимы — менять фабрику не нужно. + +### 2. Использование в компоненте \{#suspense-usage\} + +```tsx title="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 ( -
-

Post id: {post.id}

-
-

{post.title}

+ return (
-

{post.body}

+

{ post.title }

+

{ post.body }

-
-
Owner: {post.userId}
-
- ); + ); }; ``` -### Преимущества использования Фабрики запросов -- **Структурирование запросов:** Фабрика позволяет организовать все запросы к API в одном месте, что делает код более читаемым и поддерживаемым. -- **Удобный доступ к запросам и ключам:** Фабрика предоставляет удобные методы для доступа к различным типам запросов и их ключам. -- **Возможность рефетчинга запросов:** Фабрика обеспечивает возможность легкой рефетчинга без необходимости изменения ключей запросов в разных частях приложения. +### 3. Обёртка в `app` слое \{#suspense-app-wrapper\} -## Пагинация +```tsx title="src/app/providers/suspense-provider.tsx" +import { Suspense, type ReactNode } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; -В этом разделе рассмотрим пример функции `getPosts`, которая выполняет запрос к API для получения сущностей постов с применением пагинации. +interface SuspenseProviderProps { + children: ReactNode; +} -### 1. Создание функции `getPosts` -Функция getPosts находится в файле `get-posts.ts`, который находится в сегменте API. +export const SuspenseProvider = ({ children }: SuspenseProviderProps) => ( + Что-то пошло не так }> + Загрузка... }> + { children } + + +); +``` -```tsx title="@/pages/post-feed/api/get-posts.ts" -import { apiClient } from "@/shared/api/base"; +## `useMutationState` \{#use-mutation-state\} -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"; +`useMutationState` позволяет читать состояние мутаций из любого компонента без передачи пропсов — удобно для глобальных индикаторов загрузки или отображения статуса операции. -const calculatePostPage = (totalCount: number, limit: number) => - Math.floor(totalCount / limit); +### 1. Хранение ключей мутаций \{#storing-mutation-keys\} -export const getPosts = async ( - page: number, - limit: number, -): Promise => { - const skip = page * limit; - const query: PostQuery = { skip, limit }; - const result = await apiClient.get("/posts", query); +По аналогии с фабрикой запросов, ключи мутаций стоит хранить в одном месте рядом с фабрикой: - return { - posts: result.posts.map((post) => mapPost(post)), - limit: result.limit, - skip: result.skip, - total: result.total, - totalPages: calculatePostPage(result.total, limit), - }; +```ts title="src/shared/api/post/post.queries.ts" +export const POST_MUTATIONS = { + updateTitle: () => ['post', 'update-title'], + create: () => ['post', 'create'], }; ``` -### 2. Фабрика запросов для пагинации -Фабрика запросов `postQueries` определяет различные варианты запросов для работы с постами, -включая запрос списка постов с заранее определенной страницей и лимитом. - -```tsx -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, - }), -}; -``` +### 2. Именование мутации через `mutationKey` \{#naming-mutations\} +```tsx title="src/features/update-post/api/use-update-post-title.ts" +import { POST_MUTATIONS } from '@/shared/api/post'; -### 3. Использование в коде приложения -```tsx title="@/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 ( - <> - setPage(page)} - page={page} - count={data?.totalPages} - variant="outlined" - color="primary" - /> - - - ); -}; +interface UpdatePostTitle { + id: number; + newTitle: string; +} + +export const useUpdatePostTitle = () => + useMutation({ + mutationKey: POST_MUTATIONS.updateTitle(), + mutationFn: ({ id, newTitle }: UpdatePostTitle) => + apiClient.patch(`/posts/${id}`, { title: newTitle }), + }); ``` - -## `QueryProvider` для управления запросами -В этом гайде рассмотрим, как организовать `QueryProvider`. +### 3. Чтение состояния в другом компоненте \{#reading-mutation-state\} -### 1. Создание `QueryProvider` -Файл `query-provider.tsx` расположен по пути `@/app/providers/query-provider.tsx`. +```tsx title="src/features/update-post/ui/save-indicator.tsx" +import { useMutationState } from '@tanstack/react-query'; +import { POST_MUTATIONS } from '@/shared/api/post'; -```tsx title="@/app/providers/query-provider.tsx" -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; -import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; -import { ReactNode } from "react"; +export const SaveIndicator = () => { + const isPending = useMutationState({ + filters: { mutationKey: POST_MUTATIONS.updateTitle(), status: 'pending' }, + select: mutation => mutation.state.status, + }).length > 0; -type Props = { - children: ReactNode; - client: QueryClient; -}; - -export const QueryProvider = ({ client, children }: Props) => { - return ( - - {children} - - - ); + return isPending && ( + Сохранение... + ); }; ``` -### 2. Создание `QueryClient` -`QueryClient` представляет собой экземпляр, используемый для управления запросами к API. -Файл `query-client.ts` расположен по пути `@/shared/api/query-client.ts`. -`QueryClient` создается с определенными настройками для кэширования запросов. +## Организация `QueryProvider` \{#query-provider\} -```tsx title="@/shared/api/query-client.ts" -import { QueryClient } from "@tanstack/react-query"; +```tsx title="@/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; +} -export const queryClient = new QueryClient({ - defaultOptions: { - queries: { - staleTime: 5 * 60 * 1000, - gcTime: 5 * 60 * 1000, +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 ( + + { children } + + + ); +}; ``` -## Кодогенерация +## Кодогенерация \{#code-generation\} Существуют инструменты для автоматической генерации кода, -которые менее гибкие, по сравнению с теми, что можно настроить, -как описано выше. Если ваш Swagger-файл хорошо структурирован, и вы используете одно из таких инструментов, -то возможно имеет смысл сгенерировать весь код в каталоге @/shared/api. + которые менее гибкие, по сравнению с теми, что можно настроить, + как описано выше. Если ваш Swagger-файл хорошо структурирован, и вы используете одно из таких инструментов, + то возможно имеет смысл сгенерировать весь код в каталоге @/shared/api. -## Дополнительный совет по организации RQ -### API-Клиент +## Дополнительный совет по взаимодействию с запросами \{#api-client\} Используя собственный класс клиента API в общем слое shared, -можно стандартизировать настройку и работу с API в проекте. -Это позволяет управлять логированием, заголовками и форматом обмена данными (например, JSON или XML) из одного места. -Такой подход облегчает поддержку и развитие проекта, поскольку упрощает изменения и обновления взаимодействия с API. + можно стандартизировать настройку и работу с API в проекте. + Это позволяет управлять логированием, заголовками и форматом обмена данными (например, JSON или XML) из одного места. + Такой подход облегчает поддержку и развитие проекта, поскольку упрощает изменения и обновления взаимодействия с API. ```tsx title="@/shared/api/api-client.ts" import { API_URL } from "@/shared/config"; export class ApiClient { - private baseUrl: string; - - constructor(url: string) { - this.baseUrl = url; - } + #baseUrl: string; - async handleResponse(response: Response): Promise { - if (!response.ok) { - throw new Error(`HTTP error! Status: ${response.status}`); + constructor(url: string) { + this.#baseUrl = url; } - try { - return await response.json(); - } catch (error) { - throw new Error("Error parsing JSON response"); + async handleResponse(response: Response): Promise { + 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( - endpoint: string, - queryParams?: Record, - ): Promise { - const url = new URL(endpoint, this.baseUrl); + public async get(endpoint: string, queryParams?: Record): Promise { + const url = new URL(endpoint, this.#baseUrl); if (queryParams) { - Object.entries(queryParams).forEach(([key, value]) => { - url.searchParams.append(key, value.toString()); - }); + Object.entries(queryParams).forEach(([key, value]) => { + url.searchParams.append(key, value.toString()); + }); } + const response = await fetch(url.toString(), { - method: "GET", + method: 'GET', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, }); return this.handleResponse(response); } - public async post>( - endpoint: string, - body: TData, - ): Promise { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method: "POST", + public async post>(endpoint: string, body: TData): Promise { + const response = await fetch(`${ this.#baseUrl }${ endpoint }`, { + method: 'POST', headers: { - "Content-Type": "application/json", + 'Content-Type': 'application/json', }, body: JSON.stringify(body), }); @@ -432,4 +554,4 @@ export const apiClient = new ApiClient(API_URL); - [(CodeSandbox) Пример проекта](https://codesandbox.io/p/github/ruslan4432013/fsd-react-query-example/main) - [О фабрике запросов](https://tkdodo.eu/blog/the-query-options-api) -[public-api-for-cross-imports]: /ru/docs/reference/public-api#public-api-for-cross-imports +[public-api-for-cross-imports]: /ru/docs/reference/public-api#public-api-for-cross-imports \ No newline at end of file