안녕하세요 상훈입니다.
이번 포스팅에서는 React.js 의 라이브러리 중 하나인 React-Query 를 사용해보려고 합니다.
소스를 구현하고 동작시켜보는데, tailwindcss + cursor ai 의 많은 도움을 받았답니다..
일단 결과물 먼저 보고 오시죠!
Result
1. React-Query 설치
당연하게 npm i react-query 했는데, 오류나면서 안된다길래. 뭐지..? 싶어서 열심히 검색해왔다.
"@tanstack/react-query" : "^5.67.2"
npm uninstall react-query
npm install @tanstack/react-query
https://tanstack.com/
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와 유사하네요
QueryClientProvider
<React.StrictMode>
<QueryClientProvider client ={queryClient} >
<BrowserRouter >
<Header />
<App />
</BrowserRouter >
</QueryClientProvider >
</React.StrictMode>
3. 본격적으로 React-Query 를 사용해보자
소스 코드 전체 첨부(최하단에 있어요)
3-1. API's
rest api 형태로 mockapi 데이터 조회,수정,삭제 하는 로직
전역변수로 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 코드에서도 사용할 수 있다.
isLoading, error 의 값 활용
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로 한거지..? 라는 생각이 든다.
typescript with useQuery
**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 >
);
};
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;
이상입니다.