Skip to main content

Usage with TanStack Query

Where to store keys

└── src/
├── app/
├── pages/
├── widgets/
├── features/
├── entities/
└── shared/
└── api/
└── queries/ # Query factories
├── example.ts
└── another-example.ts
src/shared/api/index.ts
export { exampleQueries } from './queries/example';

Where to store mutations

It is not recommended to mix mutations with queries. There are several options:

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

Organising queries

Query factory

A query factory is an object where key values are functions that return a list of query keys.

const keyFactory = {
all: () => ["entity"],
lists: () => [...keyFactory.all(), "list"],
};
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).

import { queryOptions } from '@tanstack/react-query';

const groupOptions = (id: number) => queryOptions({
queryKey: ['groups', id],
queryFn: () => fetchGroups(id),
gcTime: 5 * 1000,
});

1. Creating a query factory

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. Using a query factory in application code

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

const {
data: post,
error,
isLoading,
isError
} = useQuery(postApi.POST_QUERIES.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>
);
};

Benefits of using a query factory

  • Structured requests: A factory allows you to organise 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.
  • Easy refetching: The factory makes it easy to refetch without needing to change query keys in different parts of the application.

Pagination

Pagination uses the same query factory from the organising queries section above, with the addition of placeholderData to prevent the UI from flickering when navigating between pages.

Usage in a component

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

Infinite scroll

useInfiniteQuery is used to implement "load more" or infinite scroll patterns.

1. Query factory with 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. Usage in a component

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 ? 'Loading...' : 'Load more' }
</button>
) }
</>
);
};

Suspense mode

useSuspenseQuery allows you to use React Suspense for handling loading states, removing the need to check isLoading manually.

1. The query factory remains the same

queryOptions and useSuspenseQuery are compatible — no changes to the factory are needed.

2. Usage in a component

src/pages/post/ui/post.tsx
import { useSuspenseQuery } from '@tanstack/react-query';
import { postApi } from '@/shared/api/post';

interface PostProps {
id: number;
}

// 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 (
<div>
<h1>{ post.title }</h1>
<p>{ post.body }</p>
</div>
);
};

3. Wrapper in the app layer

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>Something went wrong</div> }>
<Suspense fallback={ <div>Loading...</div> }>
{ children }
</Suspense>
</ErrorBoundary>
);

useMutationState

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

By analogy with the query factory, mutation keys should be stored in one place alongside the factory:

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

2. Naming mutations via 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. Reading state in another component

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 && (
<span>Saving...</span>
);
};

Organising QueryProvider

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

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

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 on API interaction

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.

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

See also