안녕하세요 상훈입니다.
이번 포스팅에서는 React.js 의 라이브러리 중 하나인 React-Query 를 사용해보려고 합니다.
소스를 구현하고 동작시켜보는데, tailwindcss + cursor ai 의 많은 도움을 받았답니다..
일단 결과물 먼저 보고 오시죠!
1. React-Query 설치
당연하게 npm i react-query 했는데, 오류나면서 안된다길래. 뭐지..? 싶어서 열심히 검색해왔다.
"@tanstack/react-query": "^5.67.2" //해당 버전을 다운로드 받으면 된다.
//기존의 react-query는 삭제해준다.
npm uninstall react-query
//tanstack의 버전으로 다운로드 해준다.
npm install @tanstack/react-query
TanStack | High Quality Open-Source Software for Web Developers
Headless, type-safe, powerful utilities for complex workflows like Data Management, Data Visualization, Charts, Tables, and UI Components.
tanstack.com
이곳에서 이제 다운로드 받을 수 있다고 합니다.
2. Main.tsx
본인 프로젝트의 기본 경로의 파일에서 QueryClientProvider 로 Client 를 제공해줍니다.
ContextAPI와 유사하네요
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Header />
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>
3. 본격적으로 React-Query 를 사용해보자
소스 코드 전체 첨부(최하단에 있어요)
3-1. API's
전역변수로 api 상수를 선언하고 그 안에 [조회, 수정, 삭제] 가 가능한 로직을 만들었습니다.
각각 GET, PUT(UPDATE), DELETE 의 method 를 가지고 있습니다.
3-2. 메인 로직 - useQuery
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
//...
const api = {
fetchPosts: async ({ pageParam = 1, searchTerm = "" }) => {
const response = await fetch(
`${POSTS_URL}?page=${pageParam}&limit=10&search=${searchTerm}`
);
return response.status === 200 ? response.json() : [];
},
//...
}
//...
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const queryClient = useQueryClient();
// 쿼리 키
const postsQueryKey = ["posts", currentPage, debouncedSearch];
// 데이터 조회 쿼리
const {
data: posts = [],
isLoading,
error,
} = useQuery({
queryKey: postsQueryKey,
queryFn: () =>
api.fetchPosts({
pageParam: currentPage, //페이징처리
searchTerm: debouncedSearch, //검색기능
}),
});
중간에 이것저것 있지만, 다 걷어내고 난 후에 메인 기능은 위와 같다.
postQueryKey 의 요소들이 결국 useQuery의 의존성배열이 되는 것인데,
마치 useEffect의 2번째 파라미터로 넘기는 의존성배열과 같다.
1) "posts"의 테이블로 데이터 요청을 할 것이다.
2) currentPage 의 값이 변경될 때마다 요청할 것이다.
3) debouncedSearch 의 값이 변경될 때마다 요청할 것이다.
결과는 [ posts, isLoading, error ] 의 값을 템플릿 리터럴으로 html 코드에서도 사용할 수 있다.
1) isLoading: 로딩바 구현
2) error: 에러 시 table 목록을 보여주지 않고 위 텍스트만 노출.
3) posts 를 반복하여 tr 태그에 값을 내려준다.
3-3. useMutation
mutation 의 경우는 3단계로 나뉘어진다.
1) useMutation 선언
2) createMutation 호출
3) invalidateQueries 호출
이렇게 글로 정리하니까 되게 간단해보이지만 실제로 써놓은 코드 보면 헷갈릴 수 있다.
// 뮤테이션 공통 성공 핸들러
const handleMutationSuccess = () => {
queryClient.invalidateQueries({ queryKey: postsQueryKey });
};
// 뮤테이션 생성 헬퍼 함수에 제네릭 타입 추가
const createMutation = (mutationFn) => {
const mutation = useMutation({
mutationFn,
onSuccess: handleMutationSuccess,
});
return mutation;
};
// 업데이트와 삭제 뮤테이션
const updateMutation = createMutation(api.updatePost);
const deleteMutation = createMutation(api.deletePost);
return (
{/* 사이드바 */}
{selectedPost && (
<SideBar
post={selectedPost}
onClose={closeSideBar}
onUpdate={(updatedPost) => {
updateMutation.mutate(updatedPost);
closeSideBar();
}}
onDelete={(postId) => {
deleteMutation.mutate(postId);
closeSideBar();
}}
/>
)}
)
수정
Sidebar 에서 정보를 업데이트하여 onUpdate 가 호출 > updateMutation.mutate(updatedPost) 호출 > useMutation(updatePost) 호출 > [실제쿼리동작] > queryClient.invalidateQueries 호출하여 리스트 재조회
- 캐싱된 내용은 그대로 있고, 캐싱되지 않은 내용만 새로 업데이트 된다.
삭제
마찬가지로 Sidebar 에서 정보를 삭제 시 onDelete 호출 > deleteMutation.mutate(postId) 호출 > useMutation(deletePost) 호출 > [실제쿼리동작] > queryClient.invalidateQueries 호출하여 리스트 재조회
- 캐싱 동일 적용
최대한 요약해봤지만 결국 장황하게 쓰게되었다.
하지만 결론은 생각보다 간단하다
1. 최대한 re-rendering 발생하지 않도록 caching 처리
2. 리스트 기준으로 결국에는 list 재조회를 한다.
공식문서에 TypeScript 관련하여 useQuery 부분에 대해 선언해놓은게 있는데, <T...> 다 제너릭으로 설정할거면 뭐하러 Typescript로 한거지..? 라는 생각이 든다.
**3). UseReactQuery.tsx 소스코드 전체
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { useState, useEffect } from "react";
import { ClipLoader } from "react-spinners";
import { PostTableRow } from "../components/PostTableRow";
import SideBar from "../components/SideBar";
import { toast, ToastContainer } from "react-toastify";
import "react-toastify/dist/ReactToastify.css";
const POSTS_URL = "https://mockapi.io/" //이곳에서 프로젝트 생성하시고 랜덤 데이터 생성할 수 있어요!
type POST_TYPE = {
id: string;
createdAt: string;
writer: string;
title: string;
avatar: string;
contents: string;
};
export const IsLoading = () => {
return (
<div className="flex justify-center items-center h-screen">
<ClipLoader color="#4A90E2" size={50} />
</div>
);
};
// API 함수들
const api = {
fetchPosts: async ({ pageParam = 1, searchTerm = "" }) => {
const response = await fetch(
`${POSTS_URL}?page=${pageParam}&limit=10&search=${searchTerm}`
);
return response.status === 200 ? response.json() : [];
},
updatePost: async (updatedPost: POST_TYPE) => {
const response = await fetch(`${POSTS_URL}/${updatedPost.id}`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify(updatedPost),
});
if (response.status === 200) {
toast.success("게시물이 성공적으로 업데이트되었습니다.");
return true;
} else {
toast.error("게시물 업데이트에 실패했습니다.");
return false;
}
},
deletePost: async (postId: string) => {
const response = await fetch(`${POSTS_URL}/${postId}`, {
method: "DELETE",
});
if (response.status === 200) {
toast.success("게시물이 성공적으로 삭제되었습니다.");
return true;
} else {
toast.error("게시물 삭제에 실패했습니다.");
return false;
}
},
};
// 페이지네이션 컴포넌트
const Pagination = ({
currentPage,
setCurrentPage,
hasMore,
}: {
currentPage: number;
setCurrentPage: (page: number) => void;
hasMore: boolean;
}) => (
<div className="mt-4 flex justify-center gap-2">
<button
onClick={() => setCurrentPage(Math.max(currentPage - 1, 1))}
disabled={currentPage === 1}
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
>
이전
</button>
<span className="px-4 py-2">페이지 {currentPage}</span>
<button
onClick={() => setCurrentPage(currentPage + 1)}
disabled={!hasMore}
className="px-4 py-2 bg-gray-200 rounded disabled:opacity-50"
>
다음
</button>
</div>
);
// 메인 컴포넌트
const UseReactQuery = () => {
const [currentPage, setCurrentPage] = useState(1);
const [searchTerm, setSearchTerm] = useState("");
const [debouncedSearch, setDebouncedSearch] = useState("");
const [selectedPost, setSelectedPost] = useState<POST_TYPE | null>(null);
const queryClient = useQueryClient();
// 디바운스 처리
useEffect(() => {
const timer = setTimeout(() => {
setDebouncedSearch(searchTerm);
setCurrentPage(1); // 검색어 변경시 첫 페이지로 이동
}, 500);
return () => clearTimeout(timer);
}, [searchTerm]);
// 쿼리 키
const postsQueryKey = ["posts", currentPage, debouncedSearch];
// 데이터 조회 쿼리
const {
data: posts = [],
isLoading,
error,
} = useQuery({
queryKey: postsQueryKey,
queryFn: () =>
api.fetchPosts({
pageParam: currentPage,
searchTerm: debouncedSearch,
}),
});
// 뮤테이션 공통 성공 핸들러
const handleMutationSuccess = () => {
queryClient.invalidateQueries({ queryKey: postsQueryKey });
};
// 뮤테이션 생성 헬퍼 함수에 제네릭 타입 추가
const createMutation = <TData, TVariables>(
mutationFn: (variables: TVariables) => Promise<TData>
) => {
// eslint-disable-next-line react-hooks/rules-of-hooks
const mutation = useMutation({
mutationFn,
onSuccess: handleMutationSuccess,
});
return mutation;
};
// 업데이트와 삭제 뮤테이션
const updateMutation = createMutation<boolean, POST_TYPE>(api.updatePost);
const deleteMutation = createMutation<boolean, string>(api.deletePost);
// 사이드바 관련 함수
const openSideBar = (post: POST_TYPE) => setSelectedPost(post);
const closeSideBar = () => setSelectedPost(null);
return (
<div className="mt-6 relative">
<ToastContainer />
<h1>UseReactQuery</h1>
{/* 검색 필터 */}
<div className="mb-4 flex flex-col gap-2">
<div className="flex justify-end">
<input
type="text"
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
placeholder="제목 또는 내용으로 검색..."
className="px-4 py-2 border rounded-md w-64"
/>
</div>
<div className="h-6">
{searchTerm && (
<p className="text-sm text-gray-500 text-right">
총 {posts.length}개의 결과가 있습니다.
</p>
)}
</div>
</div>
{/* 상태별 표시 */}
{isLoading && <IsLoading />}
{error && <div>에러가 발생했습니다!</div>}
{/* 데이터 테이블 */}
{!isLoading && !error && (
<div className="overflow-x-auto w-screen flex flex-col">
<table className="max-w-full table-auto flex-1 text-center mx-4">
<thead className="bg-gray-100">
<tr className="text-center">
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
번호
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
제목
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
내용
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
작성자
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
작성일
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{posts.length > 0 ? (
posts.map((post: POST_TYPE, index: number) => (
<PostTableRow
key={post.id}
post={post}
index={index}
currentPage={currentPage}
onRowClick={openSideBar}
/>
))
) : (
<tr>
<td
colSpan={5}
className="text-center pt-3"
>
결과가 없습니다.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* 페이지네이션 */}
{posts.length > 0 && (
<Pagination
currentPage={currentPage}
setCurrentPage={setCurrentPage}
hasMore={posts.length >= 10}
/>
)}
{/* 사이드바 */}
{selectedPost && (
<SideBar
post={selectedPost}
onClose={closeSideBar}
onUpdate={(updatedPost) => {
updateMutation.mutate(updatedPost);
closeSideBar();
}}
onDelete={(postId) => {
deleteMutation.mutate(postId);
closeSideBar();
}}
/>
)}
</div>
);
};
export default UseReactQuery;
이상입니다.
'FrontEnd > React.js' 카테고리의 다른 글
[React / scss] import 오류 해결하기 > use 쓰기 (0) | 2025.03.17 |
---|---|
[React.js] useContext / 테마색 바꾸기 (0) | 2025.03.11 |
[React.js] useTransition & useDeferredValue 함께 사용하자 (0) | 2025.03.10 |
[React] Streaming 처리하기 3 - abort 기능 추가 (0) | 2025.03.06 |
[React.js] Stream 데이터 fetch로 처리하기 - 2 (0) | 2025.02.28 |