TanStack Query 페이지 전환 중 이전 목록을 어떻게 유지하나요?
페이지를 바꿀 때 queryKey에 페이지 번호를 넣어 서로 다른 목록을 구분합니다. placeholderData로 이전 목록을 잠시 유지하면 다음 응답을 기다리는 중에도 빈 화면을 피할 수 있습니다.
적용 조건·범위: 새 페이지를 기다리는 동안 이전 성공 목록을 임시로 보여 줄 수 있습니다. 이 목록은 새 페이지의 확정 결과가 아니므로 isPlaceholderData 표시와 이동 버튼 조건을 함께 확인합니다.
본문 근거: tanstack.com/query/latest/docs/framework/react/guides/paginated-queries · tanstack.com/query/latest/docs/framework/react/reference/functions/keepPreviousData
이 기술만 따로 연습하기
기대 결과: 다음 버튼을 누르면 요청 페이지는 2이지만 0.6초 동안 1쪽 목록과 이전 결과 표시 중이 남고, 이후 2쪽으로 교체됩니다. 3쪽에서는 다음 버튼이 비활성화됩니다.
직접 실행: 환경 준비와 전체 파일
실행 환경은 React·TypeScript와 @tanstack/react-query 5.104.0입니다. React 환경이 없다면 React Vite 사용법 Vite 8 프로젝트 생성 실행 빌드를, 라이브러리 설치는 TanStack Query Provider와 첫 useQuery 상태 처리 실습 가이드의 설치 항목을 확인합니다. 아래는 별도 Vite 연습 앱 src/App.tsx 전체 내용입니다. src/main.tsx는 유지하며 이전 TaskFlow나 API 서버는 필요하지 않습니다. 요청 함수는 이 파일 안에 정의된 비동기 모형입니다.
import { useState } from 'react';
import {
QueryClient,
QueryClientProvider,
useQuery,
keepPreviousData
} from '@tanstack/react-query';
const wait = (ms = 400) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const client = new QueryClient({
defaultOptions: { queries: { retry: false, refetchOnWindowFocus: false } },
});
async function readPage(page: number) {
await wait(600);
return { items: [`${page}쪽 A`, `${page}쪽 B`], hasMore: page < 3 };
}
function Demo() {
const [page, setPage] = useState(1);
const query = useQuery({
queryKey: ['pages', page],
queryFn: () => readPage(page),
placeholderData: keepPreviousData,
});
return (
<main>
<p>요청 페이지 {page} / {query.isPlaceholderData ? '이전 결과 표시 중' : '현재 결과'}</p>
{query.isPending ? <p>초기 요청</p>
: query.isError ? <p>{query.error.message}</p>
: <ul>{query.data.items.map((item) => <li key={item}>{item}</li>)}</ul>}
<button disabled={page === 1 || query.isFetching} onClick={() => setPage((value) => value - 1)}>이전</button>
<button disabled={query.isFetching || !query.data?.hasMore} onClick={() => setPage((value) => value + 1)}>다음</button>
</main>
);
}
export default function App() {
return (
<QueryClientProvider client={client}>
<Demo />
</QueryClientProvider>
);
}
npm run dev
npm run build
자가 확인·변형: placeholderData를 제거하면 페이지 전환 때 초기 요청 UI가 나타납니다. 이전 결과가 2쪽의 확정 데이터인 듯 표시하지 않는 이유를 설명하세요.
검증 기록: 위 전체 코드는 타입검사·빌드와 jsdom 동작 검증을 통과했습니다. 실제 브라우저 화면과 누적 Next.js 프로젝트 통합 실행은 별도 확인 대상입니다.
페이지별 queryKey
const [page, setPage] =
useState(1);
const query = useQuery({
queryKey: [
'tasks',
'list',
{ page },
],
queryFn: () =>
getTaskPage(page),
});
page가 1에서 2로 바뀌면 queryKey도 달라집니다.
page 1
→ ['tasks', 'list', { page: 1 }]
page 2
→ ['tasks', 'list', { page: 2 }]
각 페이지 결과가 독립적으로 캐시됩니다.
기본 페이지 전환의 문제
새 페이지 cache가 아직 없다면 다음 데이터가 올 때까지 pending UI로 전환될 수 있습니다.
1페이지 목록
→ 다음 클릭
→ 로딩 화면
→ 2페이지 목록
목록이 사라졌다 나타나면 화면이 깜빡이는 느낌을 줄 수 있습니다.
placeholderData: keepPreviousData
import {
keepPreviousData,
useQuery,
} from '@tanstack/react-query';
const query = useQuery({
queryKey: [
'tasks',
'list',
{ page },
],
queryFn: () =>
getTaskPage(page),
placeholderData:
keepPreviousData,
});
새 query 결과를 기다리는 동안 이전 성공 데이터를 placeholder로 보여줍니다.
isPlaceholderData
현재 보이는 데이터가 새 page의 실제 결과인지 이전 데이터를 임시로 보여주는 상태인지 구분할 수 있습니다.
<button
type="button"
disabled={
query.isPlaceholderData ||
!query.data?.nextPage
}
onClick={() =>
setPage((value) => value + 1)
}
>
다음
</button>
API 반환 shape
페이지네이션 응답은 다음처럼 명확하게 만듭니다.
export type TaskPage = {
items: Task[];
page: number;
nextPage: number | null;
};
API 함수:
export async function getTaskPage(
page: number,
): Promise<TaskPage> {
const response = await fetch(
'/api/tasks?page=' +
page +
'&pageSize=5',
);
if (!response.ok) {
throw new Error(
'작업 목록을 불러오지 못했습니다.',
);
}
return response.json();
}
페이지는 로컬 state인가 서버 state인가
현재 선택한 page 번호는 사용자의 화면 탐색 상태입니다.
const [page, setPage] =
useState(1);
서버에서 받은 page의 items는 TanStack Query가 관리합니다.
현재 page 번호
→ React state 또는 URL search params
page별 서버 목록
→ TanStack Query
URL에 page를 넣는 경우
검색 결과 페이지처럼 뒤로가기·공유 URL이 중요하다면 page를 searchParams로 관리할 수도 있습니다.
이 글에서는 Query의 페이지 cache에 집중하기 위해 useState를 사용합니다.
placeholderData와 initialData 차이
placeholderData는 임시 표시용이며 해당 placeholder 자체를 실제 query cache 데이터로 저장하는 목적이 아닙니다.
initialData는 query의 초기 cache 데이터로 사용됩니다.
페이지 이동에서 이전 page를 잠깐 보여주는 경우 placeholderData가 더 자연스럽습니다.
학습 완료 체크리스트
- page 변수를 queryKey에 포함할 수 있습니다.
- 페이지마다 별도 cache가 만들어지는 이유를 설명할 수 있습니다.
- placeholderData로 페이지 전환 깜빡임을 줄일 수 있습니다.
- keepPreviousData와 isPlaceholderData를 사용할 수 있습니다.
- page UI 상태와 서버 목록 데이터를 분리할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 서버 데이터의 조회·변경과 캐시를 관리합니다.
TaskFlow 작업 목록 API에 page/pageSize를 추가하고 5개 단위 페이지네이션을 구현합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
중간부터 시작하는 전체 실습 파일
기존 Next.js Route Handler TaskFlow의 /tasks 화면과 /api/tasks를 이어서 확장합니다. 인증 없는 demo 메모리 저장소를 사용하는 로컬 실습이며 계정·외부 DB·API 키가 필요 없습니다. MSW를 활성화하지 않습니다. 서버를 다시 시작하면 데이터가 초기화됩니다.
이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.
시작본에서 직접 바꾸기
시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.
app/api/tasks/route.ts— 기존 파일의 같은 위치를 갱신합니다.app/tasks/PaginatedTaskList.tsx— 이전 단계에 없던 파일을 만듭니다.app/tasks/TaskDashboard.tsx— 기존 파일의 같은 위치를 갱신합니다.
변경 이유와 결과: GET의 page/pageSize 계약과 PaginatedTaskList를 추가합니다. 이전 데이터 placeholder 동안 다음 버튼을 막고 마지막 페이지에서 종료합니다.
완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.
app/api/tasks/route.ts 전체 파일
import { listTasks, addTask } from '@/app/lib/store';
export async function GET(request: Request) {
const params = new URL(request.url).searchParams;
if (params.get('fail') === '1') return Response.json({ message: '실습 조회 실패' }, { status: 500 });
const status = params.get('status') ?? 'all';
if (!['all', 'active', 'completed'].includes(status)) return Response.json({ message: '필터 오류' }, { status: 400 });
const rows = listTasks('demo').filter((task) => status === 'all' || (status === 'completed' ? task.completed : !task.completed));
if (params.has('page') || params.has('cursor')) {
const size = Number(params.get('pageSize') ?? '2');
if (!Number.isInteger(size) || size < 1 || size > 50) return Response.json({ message: 'pageSize 오류' }, { status: 400 });
if (params.has('cursor')) {
const cursor = params.get('cursor') ?? '';
const offset = cursor === '' ? 0 : rows.findIndex((task) => task.id === cursor) + 1;
if (cursor && offset === 0) return Response.json({ message: 'cursor 오류' }, { status: 400 });
const items = rows.slice(offset, offset + size);
return Response.json({ items, nextCursor: offset + size < rows.length ? items.at(-1)!.id : null }, { headers: { 'Cache-Control': 'private, no-store' } });
}
const page = Number(params.get('page'));
if (!Number.isInteger(page) || page < 1) return Response.json({ message: 'page 오류' }, { status: 400 });
return Response.json({ items: rows.slice((page - 1) * size, page * size), page, totalPages: Math.max(1, Math.ceil(rows.length / size)) }, { headers: { 'Cache-Control': 'private, no-store' } });
}
return Response.json(rows, { headers: { 'Cache-Control': 'private, no-store' } });
}
export async function POST(request: Request) {
try {
const body: unknown = await request.json();
if (!body || typeof body !== 'object' || !('title' in body)) throw new Error('제목 필요');
if ('simulateFailure' in body && body.simulateFailure === true) return Response.json({ message: '실습 저장 실패' }, { status: 503 });
return Response.json(addTask('demo', body.title), { status: 201 });
} catch {
return Response.json({ message: '올바른 JSON과 2~100자 제목이 필요합니다.' }, { status: 400 });
}
}
app/tasks/PaginatedTaskList.tsx 전체 파일
'use client';
import { useState } from 'react';
import { keepPreviousData, useQuery } from '@tanstack/react-query';
import { getTaskPage } from '../lib/tasks-api';
import { taskKeys } from '../lib/task-keys';
export default function PaginatedTaskList() {
const [page, setPage] = useState(1);
const query = useQuery({ queryKey: taskKeys.page(page), queryFn: ({ signal }) => getTaskPage(page, signal), placeholderData: keepPreviousData });
return (
<section aria-label="페이지 목록">
<h2>페이지 목록</h2>
{query.isPending ? <p>페이지 로딩 중</p> : query.isError ? <p role="alert">{query.error.message}</p> : <ul>{query.data.items.map((task) => <li key={task.id}>{task.title}</li>)}</ul>}
<p>페이지 {page} {query.isPlaceholderData ? '(이전 목록 유지 중)' : ''}</p>
<button disabled={page === 1 || query.isFetching} onClick={() => setPage((value) => value - 1)}>이전 페이지</button>
<button disabled={!query.data || query.isFetching || query.isPlaceholderData || page >= query.data.totalPages} onClick={() => setPage((value) => value + 1)}>다음 페이지</button>
</section>
);
}
app/tasks/TaskDashboard.tsx 전체 파일
'use client';
import { useState } from 'react';
import TaskListClient from './TaskListClient';
import type { TaskStatus } from '../lib/tasks-api';
import TaskDetailClient from './TaskDetailClient';
import TaskForm from './TaskForm';
import QueryStatePanel from './QueryStatePanel';
import PaginatedTaskList from './PaginatedTaskList';
export default function TaskDashboard() {
const [status, setStatus] = useState<TaskStatus>('all');
const [selected, setSelected] = useState<string | null>(null);
return (
<main>
<h1>TaskFlow Query</h1>
<label>필터 <select value={status} onChange={(event) => setStatus(event.target.value as TaskStatus)}>
<option value="all">전체</option><option value="active">진행 중</option><option value="completed">완료</option>
</select></label>
<TaskForm />
<TaskListClient status={status} onSelect={setSelected} />
{selected && <TaskDetailClient taskId={selected} />}
<QueryStatePanel />
<PaginatedTaskList />
</main>
);
}
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후http://localhost:3000/tasks를 엽니다.- GET의 page/pageSize 계약과 PaginatedTaskList를 추가합니다. 이전 데이터 placeholder 동안 다음 버튼을 막고 마지막 페이지에서 종료합니다.
npm run typecheck와npm run build가 성공하면 다음 프로젝트 단계로 갑니다.
기존 체크포인트 검증 기록: TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과한 기록입니다. 별도 실제 브라우저 화면 검수는 그 파일 검증 범위에 포함하지 않았습니다. 이번 문장 보완에서 재실행한 결과는 아닙니다.
아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.
연속 학습의 이전 글: TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.
설명 조각의 PagedTaskList와 실제 완료 파일 app/tasks/PaginatedTaskList.tsx를 구분합니다. 실제 파일의 page를 키에 포함하고 keepPreviousData와 버튼 disabled 조건을 연결합니다. 응답의 items와 다음 페이지 판단값은 사용한 조회 함수의 계약에 맞춥니다. 상단 독립 readPage 모형의 hasMore를 다른 응답 shape에 그대로 가정하지 않습니다.
이전 체크포인트
query-step-07-invalidation
이번 목표
TaskFlow 작업 목록 API에 page/pageSize를 추가하고 5개 단위 페이지네이션을 구현합니다.
전체 파일·코드 보기
app/tasks/PagedTaskList.tsx
'use client';
import {
keepPreviousData,
useQuery,
} from '@tanstack/react-query';
import { useState } from 'react';
import {
getTaskPage,
} from '../lib/tasks-api';
export default function PagedTaskList() {
const [page, setPage] =
useState(1);
const query = useQuery({
queryKey: [
'tasks',
'list',
{ page },
],
queryFn: () =>
getTaskPage(page),
placeholderData:
keepPreviousData,
});
if (
query.isPending &&
!query.data
) {
return <p>불러오는 중입니다.</p>;
}
if (query.isError) {
return <p>{query.error.message}</p>;
}
return (
<section>
<ul>
{query.data.items.map(
(task) => (
<li key={task.id}>
{task.title}
</li>
),
)}
</ul>
<button
type="button"
disabled={page === 1}
onClick={() =>
setPage((value) =>
Math.max(1, value - 1),
)
}
>
이전
</button>
<button
type="button"
disabled={
query.isPlaceholderData ||
query.data.nextPage ===
null
}
onClick={() =>
setPage(
(value) => value + 1,
)
}
>
다음
</button>
</section>
);
}
실행 확인
- 1페이지를 불러옵니다.
- 다음 버튼을 누릅니다.
- 요청 중에도 이전 목록이 유지되는지 확인합니다.
- 응답 후 새 목록으로 교체되는지 확인합니다.
- Network에서 page 파라미터를 확인합니다.
npm run build
현재 확장 단계
✓ 1~7. 조회·mutation·동기화
→ 8. pagination
○ 9. infinite query
○ 10. optimistic update
○ 11. 목록·상세 cache 설계
○ 12~15. SSR·Hydration
완료 체크포인트
query-step-08-pagination
다음 학습
다음 글에서는 page 버튼 대신 useInfiniteQuery와 pageParam으로 데이터를 계속 이어 붙이는 구조를 구현합니다.
다음 글: TanStack Query 무한 스크롤 사용법: useInfiniteQuery로 목록 이어 불러오기
공식 문서
- Paginated Queries: https://tanstack.com/query/latest/docs/framework/react/guides/paginated-queries
- keepPreviousData: https://tanstack.com/query/latest/docs/framework/react/reference/functions/keepPreviousData
프로젝트 적용 후 확인: 다음 페이지를 요청하는 동안 어떤 데이터가 보일까요?
관찰 절차 /tasks에서 1페이지를 본 뒤 Network 속도 제한을 켜고 2페이지를 누릅니다. Devtools의 [‘tasks’,page] 키와 화면의 이전 목록, isPlaceholderData 표시를 확인합니다. 2페이지 응답 뒤 목록을 다시 비교합니다.
예상 결과 응답 전에는 이전 데이터가 임시로 보이고, 성공 후 2페이지 데이터로 바뀝니다. 페이지마다 캐시 키가 분리됩니다.
오류 점검 페이지가 바뀌지 않으면 키에 page를 넣고 queryFn에도 page를 전달했는지 확인합니다. 이전 목록을 실제 2페이지 데이터로 오인하지 않도록 이동 중 표시를 둡니다.
다음 학습 9회차에서 다음 페이지를 기존 목록 뒤에 붙입니다.
기존 체크포인트 검증 범위 전체 체크포인트의 빌드·상태 동작은 로컬 검증을 통과했습니다. 실제 브라우저 시각 확인은 별도로 진행해야 합니다.
글 소개·학습 준비·전체 목차

TanStack Query · 8 / 15 · 중급 학습 · 페이지네이션
페이지 번호가 바뀌면 queryKey도 바뀌므로 TanStack Query는 각 페이지를 서로 다른 query로 관리합니다.
새 페이지를 기다리는 동안 이전 성공 목록을 임시로 보여 줄 수 있습니다. 이 목록은 새 페이지의 확정 결과가 아니므로 isPlaceholderData 표시와 이동 버튼 조건을 함께 확인합니다.
v5에서는 placeholderData에 keepPreviousData를 사용해 이전 페이지 데이터를 유지하면서 다음 페이지를 요청할 수 있습니다.
이번 글에서 배우는 내용
- page를 queryKey에 포함하는 이유
- 페이지별 cache
- placeholderData
- keepPreviousData
- isPlaceholderData
- 페이지 이동 버튼 제어
- TaskFlow API page 파라미터
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.