주요 콘텐츠로 건너뛰기

Usage with TanStack Query

query key를 어디에 둘 것인가

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은 어디에 둘 것인가

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 factory

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

const keyFactory = {
all: () => ["entity"],
lists: () => [...keyFactory.all(), "list"],
};
info

queryOptions는 react-query v5에 포함된 유틸리티입니다.

queryKeyqueryFn을 함께 정의해 여러 위치에서 재사용하려면 queryOptions 헬퍼 함수를 사용하는 것이 좋습니다(자세한 내용).

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

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

1. 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. 프로젝트 코드에서 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 factory의 장점

  • 요청을 한곳에서 관리할 수 있습니다.
  • 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

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

Suspense 모드

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

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

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

2. 컴포넌트에서 사용

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. app layer에 Suspense 경계 두기

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

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

1. mutation key 한곳에서 관리하기

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

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

2. mutationKey로 mutation 식별하기

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 상태 읽기

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

QueryProvider 구성하기

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에 모아 두는 방식을 고려할 수 있습니다.

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

참고 자료