React Query는 서버 상태(Server State) 를 관리하기 위한 라이브러리다.
API 데이터의 캐싱, 동기화, 백그라운드 업데이트를 자동으로 처리해준다.
// 클라이언트 상태 (Redux, useState)
const [isModalOpen, setIsModalOpen] = useState(false);
const [theme, setTheme] = useState("dark");
// 서버 상태 (React Query)
const tasks = await fetch("/api/tasks");
const user = await fetch("/api/user");
-> React Query가 문제를 해결
npm install @tanstack/react-query
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
retry: 1,
},
},
});
Query Key는 캐시를 식별하는 고유한 ID다.
queryKey: ["monthlyTasks", accountId, year, month]
| 개념 | 설명 |
|---|---|
| Query Key | 캐시 식별자 |
| useQuery | 서버 데이터 조회 |
| staleTime | API 호출 없이 유지되는 시간 |
| cacheTime | 캐시 보관 시간 |
| queryClient | 캐시 직접 제어 |
기존에 Loader를 사용하여 초기 데이터를 로드하니, 사이드바를 클릭할때마다 api를 호출했었다.
// 초기 데이터 로드 (loader에서 가져온 현재 월 데이터)
useEffect(() => {
const loadInitialData = async () => {
try {
const tasks = await loaderData.items;
setCurrentTasks(tasks);
console.log(tasks);
} catch (error) {
console.error("초기 데이터 로드 실패:", error);
}
};
loadInitialData();
}, [loaderData.items]);
// loaders/index.ts
export const monthlyTasksLoader = () => {
const accountId = localStorage.getItem("accountId");
const month = new Date().getMonth() + 1;
const year = new Date().getFullYear();
console.error(accountId, month, year);
const fetcher = createApiCall<MonthlyTasksResponse>(APIS.TASKS.MONTHLY_LIST, {
accountId,
month,
year,
});
return createLoaderResultForTask(fetcher);
};
너무 비효율적이어서 React Query를 이용해보았다.
// taskService
export const taskService = {
// 일일 태스크 조회
getDailyTasks: async (accountId: string, createdAt: string) => {
const response = await apiCall<{ totalCount: number; tasks: DailyTasksType[] }>(APIS.TASKS.LIST, { accountId, createdAt });
return response.tasks;
},
// 완료된 태스크 조회
getCompletedTasks: async (accountId: number) => {
const response = await apiCall<CompletedTaskListResponse>(APIS.TASKS.COMPLETED_LIST(accountId), {});
return response;
},
// 월별 태스크 조회
getMonthlyTasks: async (accountId: string, year: number, month: number) => {
const response = await apiCall<{ totalCount: number; tasks: MonthlyTasksType[] }>(APIS.TASKS.MONTHLY_LIST, { accountId, year, month });
return response.tasks;
},
};
API 호출을 서비스로 따로 뺏다.
// (코드 생략)
const { data: currentTasks = [], isLoading: loading } = useQuery({
queryKey: ["monthlyTasks", accountId, currentMonth.year, currentMonth.month],
queryFn: () => taskService.getMonthlyTasks(accountId, currentMonth.year, currentMonth.month),
staleTime: 5 * 60 * 1000,
cacheTime: 10 * 60 * 1000,
refetchOnWindowFocus: false,
enabled: !!accountId,
});
const handleTodoUpdate = (taskId: number, completed: boolean) => {
// 캐시 업데이트 (낙관적 업데이트)
queryClient.setQueryData(["monthlyTasks", accountId, currentMonth.year, currentMonth.month], (old: MonthlyTasksType[] = []) =>
old.map((task) => (task.taskId === taskId ? { ...task, isCompleted: completed } : task))
);
if (selectedTodo && selectedTodo.taskId === taskId) {
setSelectedTodo((prev) => (prev ? { ...prev, isCompleted: completed } : null));
}
};
const handleTodoDelete = (taskId: number) => {
// 캐시에서 삭제
queryClient.setQueryData(["monthlyTasks", accountId, currentMonth.year, currentMonth.month], (old: MonthlyTasksType[] = []) =>
old.filter((task) => task.taskId !== taskId)
);
setIsModalOpen(false);
setSelectedTodo(null);
};
그리고 UI에 바로 표시해줘야하는건 캐시 업데이트를 통해 구현을 했다.
일단 월별 보기 화면만 좀 바꿨는데 훨씬 효율적이었다.
children: [
{ index: true, element: <Navigate to="todo/daily" replace /> },
{
path: "todo/daily",
element: <DailyTodoListPage />,
loader: dailyTasksLoader, // defer({ totalCount, items }) 반환
},
{ path: "todo/month", element: <MonthlyTodoListPage /> /* loader: monthlyTasksLoader */ },
{ path: "todo/complete", element: <CompletePage />, loader: completedTasksLoader },
{ path: "groups/own", element: <GroupListPage />, loader: groupListLoader },
{ path: "groups/pending", element: <GroupInvitationPage />, loader: groupPendingListLoader },
{ path: "groups/request", element: <GroupRequestPage />, loader: groupPendingListLoader },
{ path: "groups/joined", element: <JoinedGroupPage />, loader: joinedGroupListLoader },
{ path: "etc/settings", element: <SettingPage /> },
{ path: "*", element: <ErrorPage /> },
],
라우터도 일단 주석처리를 했다.

더이상 사이드바를 아무리 클릭해도 api호출이 되지 않았다.
무조건 쓰는 라이브러리는 아니다.
“서버 상태”일 때만 쓰는 게 정답이다.
서버에 저장된 데이터 -> React Query
UI·임시·입력 상태 -> React Query 사용 x
- 사용자 목록
- 할 일(Task) 리스트
- 게시글 / 댓글
- 월별 캘린더 데이터
특징
👉 useQuery 사용
useQuery(["posts"], fetchPosts);
useQuery(["post", postId], fetchPost);
장점
queryKey: ["tasks", userId, year, month];
useMutation(updateTask, {
onMutate: optimisticUpdate,
});
useQuery(["user"]);
Redux 없이도 충분한 경우 많음
const [isModalOpen, setIsModalOpen] = useState(false);
const [selectedTab, setSelectedTab] = useState("calendar");
const [title, setTitle] = useState("");
useEffect(() => {
fetch("/health-check");
}, []);
const completedCount = tasks.filter(t => t.done).length;
| 질문 | YES | NO |
|---|---|---|
| 서버 데이터인가? | React Query | useState |
| 재사용되는가? | React Query | useState |
| 캐시가 유용한가? | React Query | useEffect |
| UI 상태인가? | x | useState |
서버에서 오고, 저장되고, 공유되는 데이터 → React Query
UI·입력·임시 상태 → useState / useReducer