Usage with TanStack Query
Where to store keys
Section titled “Where to store keys”Directorysrc/
Directoryapp/
- …
Directorypages/
- …
Directorywidgets/
- …
Directoryfeatures/
- …
Directoryentities/
- …
Directoryshared/
Directoryapi/
Directoryqueries/ Query factories
- example.ts
- another-example.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.
Directorysrc/
Directoryapp/
- …
Directorypages/
- …
Directorywidgets/
- …
Directoryfeatures/
- …
Directoryentities/
- …
Directoryshared/
Directoryapi/
Directoryexample/
- 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
Directoryanother-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
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:
Directorysrc/
Directoryapp/
- …
Directorypages/
- …
Directorywidgets/
- …
Directoryfeatures/
- …
Directoryentities/
Directoryexample/
Directoryapi/
- example.query.ts Query factory with keys and functions
- get-example.ts
- create-example.ts
- update-example.ts
- delete-example.ts
Directoryshared/
- …
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.
Where to store mutations
Section titled “Where to store mutations”It is not recommended to mix mutations with queries. There are several options:
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); }, });};export const Example = () => { const [title, setTitle] = useState('');
const { mutate, isPending } = useMutation({ mutationFn: mutations.createExample, });
const handleChange = ({ target: { value } }: ChangeEvent<HTMLInputElement>) => { setTitle(value); };
const handleSubmit = (e: FormEvent<HTMLFormElement>) => { e.preventDefault(); mutate({ title, userId: DEFAULT_USER_ID }); };
return ( <form onSubmit={ handleSubmit }> <Input onChange={ handleChange } value={ title } /> <Button type="submit" disabled={ isPending }>Create</Button> </form> );};Organising queries
Section titled “Organising queries”Query factory
Section titled “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"],};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
Section titled “1. Creating a query factory”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
Section titled “2. Using a query factory in application code”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
Section titled “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
Section titled “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
Section titled “Usage in a component”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
Section titled “Infinite scroll”useInfiniteQuery is used to implement “load more” or infinite scroll patterns.
1. Query factory with infiniteQueryOptions
Section titled “1. Query factory with infiniteQueryOptions”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
Section titled “2. Usage in a component”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
Section titled “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
Section titled “1. The query factory remains the same”queryOptions and useSuspenseQuery are compatible — no changes to the factory are needed.
2. Usage in a component
Section titled “2. Usage in a component”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 readyexport 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
Section titled “3. Wrapper in the app layer”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
Section titled “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
Section titled “1. Storing mutation keys”By analogy with the query factory, mutation keys should be stored in one place alongside the factory:
export const POST_MUTATIONS = { updateTitle: () => ['post', 'update-title'], create: () => ['post', 'create'],};2. Naming mutations via mutationKey
Section titled “2. Naming mutations via mutationKey”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
Section titled “3. Reading state in another component”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
Section titled “Organising QueryProvider”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
Section titled “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
Section titled “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.
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);