안녕하세요 상훈입니다.

이번 포스팅에서는 React.js 의 라이브러리 중 하나인 React-Query 를 사용해보려고 합니다.

소스를 구현하고 동작시켜보는데, tailwindcss + cursor ai 의 많은 도움을 받았답니다..

일단 결과물 먼저 보고 오시죠!

Result

 

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

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>
    );
};

// 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;

 

이상입니다.

반응형

+ Recent posts