TanStack Query useMutation 저장 성공 후 목록을 어떻게 갱신하나요?
useMutation은 저장 요청의 대기·실패·성공 상태를 관리합니다. 저장에 성공해도 목록 캐시에 새 항목이 자동으로 들어가지는 않으므로 정확한 목록 queryKey를 무효화해 다시 조회합니다.
적용 조건·범위: 실행 환경은 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 서버는 필요하지 않습니다. 요청 함수는 이 파일 안에 정의된 비동기 모형입니다.
본문 근거: tanstack.com/query/latest/docs/framework/react/guides/mutations · tanstack.com/query/latest/docs/framework/react/guides/query-invalidation
이 기술만 따로 연습하기
기대 결과: 저장하면 pending 동안 버튼이 비활성화되고 재조회 후 새 작업이 목록에 나타납니다. 실패 입력 → 저장에서는 오류가 표시되고 새 행은 생기지 않습니다.
직접 실행: 환경 준비와 전체 파일
실행 환경은 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,
useQueryClient,
useMutation
} 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 } },
});
let saved: string[] = ['기존 작업'];
async function readRows() { await wait(); return [...saved]; }
async function saveTitle(title: string) {
await wait();
if (title === '실패') throw new Error('연습용 저장 실패');
if (!title.trim()) throw new Error('제목을 입력하세요');
saved = [...saved, title.trim()];
return title.trim();
}
function Demo() {
const [title, setTitle] = useState('새 작업');
const queryClient = useQueryClient();
const query = useQuery({ queryKey: ['saved'], queryFn: readRows });
const mutation = useMutation({
mutationFn: saveTitle,
retry: false,
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ['saved'] });
setTitle('');
},
});
return (
<main>
<label>제목 <input value={title} onChange={(event) => setTitle(event.target.value)} /></label>
<button disabled={mutation.isPending} onClick={() => mutation.mutate(title)}>저장</button>
<button onClick={() => setTitle('실패')}>실패 입력</button>
{mutation.isPending && <p>저장 중</p>}
{mutation.isError && <p>{mutation.error.message}</p>}
<ul>{query.data?.map((row, index) => <li key={index}>{row}</li>)}</ul>
</main>
);
}
export default function App() {
return (
<QueryClientProvider client={client}>
<Demo />
</QueryClientProvider>
);
}
npm run dev
npm run build
자가 확인·변형: 실패 후 제목을 수정해 다시 저장할 수 있습니다. 이 저장소는 메모리 모형이라 새로고침하면 사라집니다. 실제 서버에 영구 저장했다고 해석하지 않습니다.
검증 기록: 위 전체 코드는 타입검사·빌드와 jsdom 동작 검증을 통과했습니다. 실제 브라우저 화면과 누적 Next.js 프로젝트 통합 실행은 별도 확인 대상입니다.
mutationFn 만들기
app/lib/tasks-api.ts
type CreateTaskInput = {
title: string;
};
export async function createTask(
input: CreateTaskInput,
): Promise<Task> {
const response = await fetch('/api/tasks', {
method: 'POST',
headers: {
'Content-Type':
'application/json',
},
body: JSON.stringify(input),
});
if (!response.ok) {
throw new Error(
'작업 저장에 실패했습니다.',
);
}
return response.json();
}
queryFn과 마찬가지로 HTTP 오류를 직접 던집니다.
첫 useMutation
const mutation = useMutation({
mutationFn: createTask,
});
실행할 때 mutate를 호출합니다.
mutation.mutate({
title: 'TanStack Query 공부',
});
mutation 상태
isPending
→ 서버 변경 요청 진행 중
isError
→ mutationFn 실패
isSuccess
→ mutation 성공
버튼 중복 클릭을 막을 때 isPending을 활용할 수 있습니다.
<button
type="submit"
disabled={mutation.isPending}
>
{mutation.isPending
? '저장 중...'
: '추가'}
</button>
실패 재시도
mutation은 기본적으로 실패를 자동 재시도하지 않습니다. 아래 retry: 1은 재시도를 명시한 비교 예제이며 상단 독립 앱의 retry: false와 구분합니다. 동일한 저장을 반복해도 되는 요청인지와 실패 원인을 먼저 판단한 뒤 정책을 정합니다.
useMutation({
mutationFn: createTask,
retry: 1,
});
모든 오류를 무조건 재시도하는 것이 좋은 것은 아닙니다.
예를 들어 잘못된 입력으로 발생한 400 오류는 같은 요청을 다시 보내도 성공하지 않을 가능성이 큽니다.
실제 서비스에서는 오류 종류에 따라 retry 함수를 설계할 수 있습니다.
성공 후 목록 갱신
서버에는 새 작업이 저장됐지만 기존 [‘tasks’] 캐시는 자동으로 새 항목을 포함하지 않습니다.
QueryClient를 가져옵니다.
const queryClient = useQueryClient();
성공 후 목록 query를 invalidation합니다.
const mutation = useMutation({
mutationFn: createTask,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: taskKeys.lists(),
});
},
});
무효화된 active query는 다시 서버 데이터를 확인할 수 있습니다.
TaskForm 입력은 React state
입력 중인 title까지 Query cache에 넣을 이유는 없습니다.
const [title, setTitle] =
useState('');
역할은 다음과 같습니다.
입력 중 문자열
→ React state
POST /api/tasks 상태
→ useMutation
작업 목록 원본
→ useQuery
TaskForm 예제
'use client';
import {
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { useState } from 'react';
import {
createTask,
} from '../lib/tasks-api';
import {
taskKeys,
} from '../lib/task-keys';
export default function TaskForm() {
const [title, setTitle] =
useState('');
const queryClient =
useQueryClient();
const mutation = useMutation({
mutationFn: createTask,
retry: 1,
onSuccess: async () => {
setTitle('');
await queryClient
.invalidateQueries({
queryKey:
taskKeys.lists(),
});
},
});
return (
<form
onSubmit={(event) => {
event.preventDefault();
const value = title.trim();
if (!value) return;
mutation.mutate({
title: value,
});
}}
>
<label htmlFor="task-title">
새 작업
</label>
<input
id="task-title"
value={title}
onChange={(event) =>
setTitle(
event.target.value,
)
}
/>
<button
type="submit"
disabled={
mutation.isPending
}
>
{mutation.isPending
? '저장 중...'
: '추가'}
</button>
{mutation.isError ? (
<p>
{mutation.error.message}
</p>
) : null}
</form>
);
}
학습 완료 체크리스트
- useMutation과 useQuery 역할을 구분할 수 있습니다.
- mutationFn으로 POST 요청을 분리할 수 있습니다.
- mutation pending/error 상태를 UI에 연결할 수 있습니다.
- retry를 상황에 맞게 설정할 수 있습니다.
- mutation 성공 후 관련 query를 invalidation할 수 있습니다.
선택 실습: 할 일 앱에 적용하기
TaskFlow는 작업을 추가·완료·삭제하는 할 일 앱입니다. 이 선택 실습에서는 서버 데이터의 조회·변경과 캐시를 관리합니다.
POST /api/tasks를 useMutation으로 연결하고 저장 후 목록을 다시 확인합니다.
선택: 연재 프로젝트와 전체 자료
연재 프로젝트를 이어 만드는 분을 위한 자료입니다. 이 글의 개념과 예제만 찾으셨다면 필요한 파일과 실행 방법만 펼쳐보세요.
TaskFlow 프로젝트 적용
중간부터 시작하는 전체 실습 파일
기존 Next.js Route Handler TaskFlow의 /tasks 화면과 /api/tasks를 이어서 확장합니다. 인증 없는 demo 메모리 저장소를 사용하는 로컬 실습이며 계정·외부 DB·API 키가 필요 없습니다. MSW를 활성화하지 않습니다. 서버를 다시 시작하면 데이터가 초기화됩니다.
이 글의 시작본은 직전 프로젝트 단계 완료본 전체입니다. 이미 직전 단계를 마쳤다면 그 폴더를 이어 쓰셔도 됩니다. Node.js 24.x와 npm을 사용하며 패키지 버전은 package-lock.json에 고정했습니다.
시작본에서 직접 바꾸기
시작본을 실행한 뒤 아래 변경 파일을 순서대로 수정합니다. 완료본의 같은 경로 파일에는 import와 입력 타입을 포함한 전체 코드가 있습니다.
app/tasks/TaskDashboard.tsx— 기존 파일의 같은 위치를 갱신합니다.app/tasks/TaskForm.tsx— 이전 단계에 없던 파일을 만듭니다.
변경 이유와 결과: POST /api/tasks와 TaskForm의 useMutation을 연결합니다. 성공 시 목록 무효화, 실패 시 입력 보존과 오류 표시를 확인합니다.
완료본의 docs/changes.patch에서 시작·완료 차이를, docs/current-step.md에서 파일별 적용 위치를 확인할 수 있습니다. 아래 전체 파일을 같은 경로에 반영한 다음 실행하세요.
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';
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} />}
</main>
);
}
app/tasks/TaskForm.tsx 전체 파일
'use client';
import { useState, type FormEvent } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { createTask } from '../lib/tasks-api';
import { taskKeys } from '../lib/task-keys';
export default function TaskForm() {
const [title, setTitle] = useState('');
const [fail, setFail] = useState(false);
const client = useQueryClient();
const mutation = useMutation({
mutationFn: () => createTask(title, fail),
onSuccess: async () => { setTitle(''); await client.invalidateQueries({ queryKey: taskKeys.all }); },
});
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
if (title.trim().length >= 2) mutation.mutate();
}
return (
<form onSubmit={submit}>
<label htmlFor="task-title">새 작업</label>
<input id="task-title" value={title} onChange={(event) => setTitle(event.target.value)} minLength={2} maxLength={100} required />
<label><input type="checkbox" checked={fail} onChange={(event) => setFail(event.target.checked)} />실습 저장 실패</label>
<button disabled={mutation.isPending}>추가</button>
{mutation.isPending && <p role="status">저장 중입니다.</p>}
{mutation.isError && <p role="alert">{mutation.error.message}</p>}
</form>
);
}
완료 확인
- 시작본은 직전 완료본과 동일한 ZIP입니다. 압축을 푼 뒤
npm ci로 잠금 파일의 의존성을 설치합니다. npm run dev실행 후http://localhost:3000/tasks를 엽니다.- POST /api/tasks와 TaskForm의 useMutation을 연결합니다. 성공 시 목록 무효화, 실패 시 입력 보존과 오류 표시를 확인합니다.
npm run typecheck와npm run build가 성공하면 다음 프로젝트 단계로 갑니다.
기존 체크포인트 검증 기록: TypeScript 검사·프로덕션 빌드와 실제 소스를 사용한 JSDOM/상태 시나리오 검증을 통과한 기록입니다. 별도 실제 브라우저 화면 검수는 그 파일 검증 범위에 포함하지 않았습니다. 이번 문장 보완에서 재실행한 결과는 아닙니다.
아래 부분 예제는 핵심 변경을 설명합니다. 실행에 필요한 전체 파일과 보조 타입·API는 위 완료본에서 확인하세요.
누적 실습은 이 절의 프로젝트 단계 링크를 따라 진행합니다. 페이지 끝 학습 순서는 기술 주제 읽기 순서입니다.
이전 프로젝트 단계: TanStack Query staleTime gcTime 차이: 캐시 시간 기준. 위 시작본 ZIP 전체를 내려받으면 이전 글을 다시 조립하지 않고 이 단계부터 실행할 수 있습니다.
tasks-api.ts에 저장 함수를 두고 TaskForm에서 useMutation을 연결합니다. 성공 뒤 목록 키를 invalidation하고 입력값은 로컬 state로 유지합니다.
이전 체크포인트
query-step-05-cache-time
이번 목표
POST /api/tasks를 useMutation으로 연결하고 저장 후 목록을 다시 확인합니다.
변경 파일
app/lib/tasks-api.ts
app/tasks/TaskForm.tsx
Before
POST 요청 상태와 목록 갱신을 직접 관리
After
useMutation
→ pending / error / success
onSuccess
→ task 목록 invalidation
실행 확인
- 작업 제목을 입력합니다.
- 저장 중 버튼이 비활성화되는지 확인합니다.
- Network에서 POST /api/tasks를 확인합니다.
- 성공 후 GET /api/tasks가 다시 호출되는지 확인합니다.
- 새 항목이 목록에 표시되는지 확인합니다.
npm run build
현재 확장 단계
✓ 1~5. 조회·캐시 기반
→ 6. useMutation
○ 7. 화면 갱신 문제 진단
○ 8~11. 페이지·낙관적 업데이트·캐시 동기화
○ 12~15. SSR·Hydration
완료 체크포인트
query-step-06-mutation
다음 학습
다음 글에서는 mutation이 성공했는데 화면이 바뀌지 않는 대표적인 상황을 queryKey 불일치, invalidation 범위, setQueryData 기준으로 진단합니다.
다음 프로젝트 단계: TanStack Query 화면 갱신 문제 해결: 데이터 변경 후 UI가 바뀌지 않을 때
공식 문서
- Mutations: https://tanstack.com/query/latest/docs/framework/react/guides/mutations
- Query Invalidation: https://tanstack.com/query/latest/docs/framework/react/guides/query-invalidation
프로젝트 적용 후 확인: 저장이 끝나면 목록은 언제 갱신될까요?
관찰 절차 /tasks에서 작업을 하나 저장하고 Network의 변경 요청 및 뒤따르는 GET /api/tasks를 순서대로 봅니다. 실패 응답을 한 번 만들고 오류 메시지와 목록이 그대로인지 확인한 뒤 재시도합니다.
예상 결과 성공 시 변경 요청 뒤 목록 무효화와 재조회로 새 값이 표시됩니다. 실패 시 성공 메시지와 목록 갱신이 발생하지 않아야 합니다.
오류 점검 저장 성공인데 목록이 그대로면 mutation 성공 콜백, invalidateQueries의 queryKey와 활성 목록 여부를 확인합니다.
다음 학습 7회차에서 UI가 갱신되지 않을 때 원인을 진단합니다.
기존 체크포인트 검증 범위 전체 체크포인트의 빌드·상태 동작은 로컬 검증을 통과했습니다. 실제 브라우저 시각 확인은 별도로 진행해야 합니다.
글 소개·학습 준비·전체 목차

TanStack Query · 6 / 15 · 필수 학습 · useMutation
useMutation은 저장 요청의 대기·실패·성공을 구분합니다. 저장이 성공해도 기존 목록 캐시는 자동으로 새 항목을 포함하지 않으므로 onSuccess에서 정확한 목록 키를 무효화하고 다시 조회합니다. 아래 모형에서 실패 입력과 수정 후 재저장을 각각 확인합니다.
상단은 메모리 기반 비동기 함수로 저장 중·실패·성공 후 갱신을 독립 실습합니다. 하단에서는 TaskFlow의 POST /api/tasks를 mutationFn에 연결합니다.
이번 글에서 배우는 내용
- useMutation 기본 구조
- mutationFn
- mutate와 mutateAsync
- isPending, isError, isSuccess
- retry 옵션
- 성공 후 invalidateQueries
- TaskForm 로컬 state와 서버 mutation 역할 분리
목차
이 글이 도움이 되었나요?
읽음 기록
읽음 — / 전체 15개 · 필수 14개
새 글 받아보기
RSS 리더에서 BlogFlow의 새 글을 확인할 수 있습니다.