콘텐츠로 이동

Usage with TanStack Query

query key는 보통 query factory와 API 호출 함수를 같은 곳에 관리합니다.
어느 layer에 두는지는 프로젝트 구성에 따라 달라집니다.

프로젝트 전반의 API를 shared/api 한곳에 모아 관리하려는 경우에 사용합니다.
query factory는 shared/api/queries 아래에 두고, shared/api/index.ts의 public API로 노출합니다.

  • 디렉터리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';

mutation은 query와 같은 파일에 함께 두지 않는 것을 권장합니다. 배치 방식은 여러 가지가 있습니다.

mutation은 저장, 삭제, 수정처럼 특정 사용자 동작 뒤에 실행되는 경우가 많고, 이후의 캐시 갱신이나 UI 처리도 함께 달라지기 쉽습니다.
화면 흐름과 밀접하게 연결되는 mutation은 사용하는 위치와 가까운 api segment에 custom hook 형태로 둡니다.

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

Query factory는 query key를 생성하는 함수를 모아 둔 객체입니다.

const keyFactory = {
all: () => ["entity"],
lists: () => [...keyFactory.all(), "list"],
};
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. 프로젝트 코드에서 query factory 사용하기

섹션 제목: “2. 프로젝트 코드에서 query factory 사용하기”
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>
);
};
  • 요청을 한곳에서 관리할 수 있습니다.
  • query와 query key를 일관된 방식으로 구성할 수 있습니다.
  • 같은 query key를 여러 위치에서 재사용할 수 있습니다.

페이지네이션은 query factory로 요청 구성하기에서 정의한 query factory를 그대로 사용합니다. 여기에 placeholderData 옵션을 추가해, 페이지 전환 중에도 이전 데이터를 유지합니다. 이렇게 하면 UI가 갑자기 비어 보이거나 크게 바뀌는 것을 줄일 수 있습니다.

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

무한 스크롤이나 ‘더보기’ 버튼 형태의 UI는 useInfiniteQuery로 구성합니다. 앞에서 정의한 query factory 패턴은 infiniteQueryOptions로도 그대로 확장할 수 있습니다.

1. infiniteQueryOptions로 구성한 query factory

섹션 제목: “1. infiniteQueryOptions로 구성한 query factory”
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 ? 'Loading...' : 'Load more' }
</button>
) }
</>
);
};

useSuspenseQuery를 사용하면 로딩 상태를 React Suspense로 처리할 수 있습니다. 컴포넌트에서 isLoading을 직접 확인할 필요가 없어지고, 로딩 표시는 상위 Suspense 경계가 담당합니다.

1. query factory는 그대로 재사용합니다

섹션 제목: “1. query factory는 그대로 재사용합니다”

useSuspenseQueryqueryOptions 기반 구성과 호환되므로, 앞에서 정의한 query factory를 그대로 재사용할 수 있습니다.

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

Suspense 경계는 app layer의 provider로 두고, 앱 전역 또는 필요한 라우트 단위에서 감싸 사용할 수 있습니다.

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를 사용하면 이 상태를 다른 컴포넌트에서도 확인할 수 있습니다. 예를 들어 페이지 내부의 폼에서 mutation이 진행되는 동안, 폼과 별도의 전역 헤더에서 진행 상태를 표시할 때 유용합니다. mutation key는 query factory와 비슷한 방식으로 한곳에서 관리합니다.

mutation key는 query factory와 같은 파일에 모아 둡니다.

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

3. 다른 컴포넌트에서 mutation 상태 읽기

섹션 제목: “3. 다른 컴포넌트에서 mutation 상태 읽기”
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>
);
};

QueryProviderQueryClient 설정을 앱 전역에 적용하는 위치입니다. query와 mutation의 기본 옵션, 그리고 QueryCacheMutationCache의 공통 에러 처리도 이곳에서 함께 구성합니다.

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

API 코드를 자동으로 생성하는 도구를 사용할 수도 있지만, 위에서 설명한 수동 구성만큼 세밀하게 맞추기는 어렵습니다. Swagger 파일이 잘 정리되어 있고 생성 도구를 중심으로 API 계층을 운영한다면, 생성된 코드를 @/shared/api에 모아 두는 방식을 고려할 수 있습니다.

shared layer에 API client 클래스를 두면, 프로젝트 전반의 API 호출 방식을 공통으로 관리할 수 있습니다. 로깅, 헤더, 데이터 형식(JSON, XML 등) 같은 설정도 이 위치에 함께 둘 수 있습니다. 호출 규칙이 바뀌거나 공통 설정을 추가해야 할 때는 이 위치만 수정하면 됩니다.

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