Tanstack Query(React Query) V5

하니·2025년 1월 11일

React 라이브러리

목록 보기
5/5

react-query는 react 애플리케이션에서 서버 상태 가져오기, 캐싱, 동기화 및 업데이트를 보다 쉽게 다룰 수 있도록 도와주는 라이브러리이다. 클라이언트 상태와 서버 상태를 명확히 구분하기 위해 만들어졌다.

npm install @tanstack/react-query @tanstack/react-query-devtools

🔒 시나리오

// staleTime: 5초, gcTime: 10초
useQuery({
  queryKey: ['data'],
  queryFn: fetchData,
  staleTime: 5000,
  gcTime: 10000
})

0초: 데이터 fetch, fresh 상태
5초: 데이터가 stale 상태로 변경 (아직 메모리에 있음)
페이지 이동: 쿼리 unmount → 이 시점부터 gcTime 카운트 시작
gcTime(10초) 이내 재방문: 캐시 데이터 사용 + 백그라운드 refetch
gcTime(10초) 이후 재방문: 캐시 없음, 새로 fetch

  • gcTime은 쿼리가 비활성화된 후부터 시작되는 타이머이다!

캐싱 라이프 사이클

staleTime : 데이터가 신선하다고 간주되는 시간

  • 데이터가 성공적으로 패칭된 직후부터 시작

gcTime : 비활성화된 캐시 데이터가 메모리에 유지되는 시간가비지 콜렉션 시간

  • 쿼리가 비활성화될 때 시작 ex) 컴포넌트 언마운트

요점
staleTime

  • 이 시간 동안은 같은 쿼리가 호출되어도 재요청refresh하지 않는다.
  • 시간이 지나면 stale오래된 상태가 된다. stale 상태에서는 쿼리가 재호출될 때 refreash가 발생한다.

gcTime

  • 데이터가 stale 상태여도 gcTime 동안은 캐시에 유지된다.
    즉, 데이터가 stale 상태여도 일단 캐시된 데이터를 먼저 보여주고, 백그라운드에서 새로운 데이터를 가져오는 것이다..!
    ex) 차량상세페이지: 사용자가 다른 차량을 보다가 뒤로가기로 돌아올 수 있다. 뒤로가기 시 빠른 로딩 가능

쿼리 인스턴스 마운트와 초기 데이터 패칭
1. A라는 queryKey를 가진 A 쿼리 인스턴스가 mount된다.
2. 네트워크에서 데이터를 fetch하고, 불러온 데이터는 A라는 queryKey로 캐시에 저장된다.

데이터 신선도 관리(fresh -> stale)
3. 이 데이터는 fresh상태에서 staleTime기본값 0 이후에 stale 상태로 변경된다.

쿼리 비활성화와 가비지 콜렉션
4. A 쿼리 인스턴스가 unmount되면 비활성 상태가 된다. ex) 페이지 이동
5. 캐시는 gcTime기본값 5분 만큼 유지되다가 가비지 콜렉터(GC)가 데이터를 메모리에서 제거한다.

캐시 재사용
6. 만약 gcTime 이내에 A 쿼리 인스턴스fresh한 상태가 새롭게 mount되면 캐시 데이터를 보여준다.

  • 데이터가 stale한 상태라면 백그라운드에서 refetch를 수행한다.

💠 useQuery

기본 문법

v5부터 인자로 단 하나의 객체만 받는다.

  • 필수 인자 값 : queryKey, queryFn
// 실제 예제
// 💡 queryFn의 반환 타입을 지정해주면 useQuery의 타입 추론이 원활합니다.
const getAllSuperHero = async (): Promise<AxiosResponse<Hero[]>> => {
  return await axios.get("http://localhost:4000/superheroes");
};

const { data, isLoading } = useQuery({
  queryKey: ["super-heroes"],
  queryFn: getAllSuperHero,
});

1. queryKey

쿼리 캐시 관리를 위한 고유 식별자로, 쿼리 함수에 편리하게 전달하는 역할도 한다(예시 참조).

  • useQuery의 queryKey는 배열로 지정해 줘야 한다.
const getSuperHero = async ({
  queryKey,
}: {
  queryKey: ["super-hero", number];
}): Promise<AxiosResponse<Hero>> => {
  const heroId = queryKey[1]; // ex) queryKey: ["super-hero", "3"]

  return await axios.get(`http://localhost:4000/superheroes/${heroId}`);
};

const useSuperHeroData = (heroId: string) => {
  return useQuery({
    queryKey: ["super-hero", heroId],
    queryFn: getSuperHero, // (*)
  });
};

2. queryFn

데이터를 가져오는 실제 함수

  • Promise를 반환하는 함수를 넣어야 한다.
// 상단의 queryKey 예제와 반대로 queryFn 자체적으로 인자를 받는 형태
const getSuperHero = async (heroId: string): Promise<AxiosResponse<Hero>> => {
  return await axios.get(`http://localhost:4000/superheroes/${heroId}`);
};

const useSuperHeroData = (heroId: string) => {
  return useQuery({
    queryKey: ["super-hero", heroId],
    queryFn: () => getSuperHero(heroId), // (*)
  });
};

3. options

useQuery 공식 문서 options 참고

const useSuperHeroData = (heroId: string) => {
  return useQuery({
    queryKey: ["super-hero", heroId],
    queryFn: () => getSuperHero(heroId),
    gcTime: 5 * 60 * 1000, // 5분
    staleTime: 1 * 60 * 1000, // 1분
    retry: 1,
    // ... options
  });
};

staleTime, gcTime

  • staleTime이 fresh 상태일 때는 쿼리 인스턴스가 새롭게 mount 되어도 네트워크 요청fetch이 일어나지 X는다.
  • gcTime이 지나기 전에 쿼리 인스턴스가 다시 mount 되면, 데이터를 fetch 하는 동안 캐시 데이터를 보여준다.
  • gcTime기본값 5분은 SSR 환경에서는 Infinity이다.

💡 staleTime을 gcTime보다 길게 설정했다면, staleTime만큼의 캐싱을 기대했을 때 원하는 결과를 얻지 못할 것이다. staleTime < gcTime

Polling
실시간 웹을 위한 기법으로 "일정한 주기(특정한 시간)"를 가지고 서버와 응답을 주고받는 방식이 폴링 방식이다.

  • refetchInterval은 시간(ms)를 값으로 넣어주면 일정 시간마다 자동으로 refetch 해준다.
  • refetchIntervalInBackground(boolean)는 refetchInterval과 함께 사용하는 옵션으로, 탭/창이 백그라운드에 있는 동안 refetch 시켜준다.

enabledboolean
쿼리가 자동으로 실행되지 않도록 할 때 설정한다.false

  • useQuery 반환값 중 status가 pending 상태로 시작한다.
  • 쿼리를 가져올 때는 refetch()함수를 통해 수동으로 데이터를 다시 요청해야 한다.
    ex) 버튼 클릭, 특정 이벤트enabled: !!userId // userId가 있을 때만 실행를 통한 요청 시도할 경우

select
쿼리 함수에서 반환된 데이터의 일부를 변환하거나 선택할 수 있다.

  • 쿼리 캐시에 저장되는 내용에는 영향 X
const {
  data,
  // ...
} = useQuery({
  queryKey: ["super-heroes"],
  queryFn: getAllSuperHero,
  select: (data) => {
    const superHeroNames = data.data.map((hero) => hero.name);
    return superHeroNames;
  },
});

return (
  <div>
    {data.map((heroName, idx) => (
      <div key={`${heroName}-${idx}`}>{heroName}</div>
    ))}
  </div>
);

placeholderData
쿼리가 pending 상태인 동안 특정 쿼리에 대한 placeholder data로 사용된다. placeholderData는 캐시에 유지되지 X으며, 서버 데이터와 관계없는 보여주기용 가짜 데이터이다.

  • placeholderData가 함수일 경우 인자는 (이전에 관찰된 쿼리 데이터, 이전 쿼리 인스턴스)가 된다.
    ex) 캐싱되지 않은 페이지페이지네이션를 가져올 때 목록이 깜빡거리는 현상 방지
const placeholderData = useMemo(() => generateFakeHeroes(), []);

const {
  data,
  // ...
} = useQuery({
  queryKey: ["super-heroes"],
  queryFn: getAllSuperHero,
  placeholderData: placeholderData,
});
import { useQuery } from "@tanstack/react-query";

const {
  data,
  // ...
} = useQuery({
  queryKey: ["super-heroes"],
  queryFn: getAllSuperHero,
  placeholderData: (previousData, previousQuery) => previousData,
});

주요 return 데이터

const {
  data,
  error,
  status,
  fetchStatus,
  isLoading,
  isFetching,
  isError,
  refetch,
  // ...
} = useQuery({
  queryKey: ["super-heroes"],
  queryFn: getAllSuperHero,
});

status : 쿼리 결과값에 대한 상태data가 있는지 없는지. 문자열 형태로 3가지의 값 존재

  • pending : 쿼리 데이터가 없고, 쿼리 시도가 아직 완료되지 X은 상태
    • { enabled: false } 상태로 쿼리가 호출되면 이 상태로 시작된다.Dependent Queries 공식 문서
      ex) 해당 쿼리 실행 전에 이전 쿼리가 완료되어야 할 경우userId
  • error : 에러 발생 상태
  • sucess : 데이터 표시 준비가 된 상태

fetchStatus : QueryFn에 대한 정보를 나타낸다.queryFn 요청이 진행 중인지 아닌지

  • fetching : 쿼리가 현재 실행 중인 상태
  • paused : 쿼리를 요청했지만, 잠시 중단된 상태 (network mode와 연관)
  • idle : 쿼리가 현재 아무 작업도 수행하지 X는 상태

isLoading : 캐싱된 데이터가 없을 경우 즉, 처음 실행된 쿼리일 때 로딩 여부에 따라 true/false를 반환한다.

  • 캐싱된 데이터가 있다면 로딩 여부와 상관없이 false 반환

isFetching : 캐싱된 데이터가 있더라도 쿼리가 실행되면 로딩 여부에 따라 true/false를 반환한다.

isSuccess : 쿼리 요청 성공 시 true
isError : 쿼리 요청 중 에러 발생 시 true
refetch : 쿼리를 수동으로 다시 가져오는 함수

useQueries

쿼리 여러 개를 동시에 수행하기 위해 사용한다.

  • 반환되는 순서는 퀴리가 입력된 순서와 동일하다.
const queryResults = useQueries({
  queries: [
    {
      queryKey: ["super-hero", 1],
      queryFn: () => getSuperHero(1),
      staleTime: Infinity, // 다음과 같이 option 추가 가능!
    },
    {
      queryKey: ["super-hero", 2],
      queryFn: () => getSuperHero(2),
      staleTime: 0,
    },
    // ...
  ],

Initail Query Data

쿼리에 대한 초기 데이터가 필요하기 전에 캐시를 제공하는 방법으로, 쿼리를 미리 채우는 데 사용할 수 있다.
ex) 목록 데이터가 있으면 상세 페이지 진입 시 즉시 데이터 표시 가능

const useSuperHeroData = (heroId: string) => {
  const queryClient = useQueryClient();

  return useQuery({
    queryKey: ["super-hero", heroId],
    queryFn: () => getSuperHero(heroId),
    initialData: () => {
      // 캐시된 히어로 목록 데이터 조회
      const queryData = queryClient.getQueryData(["super-heroes"]) as any;
      // 목록에서 특정 히어로 찾기
      const hero = queryData?.data?.find(
        (hero: Hero) => hero.id === parseInt(heroId)
      );
	  // 찾은 경우 초기 데이터로 제공
      if (hero) return { data: hero };
    },
  });
};

Prefetching

사용자 경험을 위해 데이터를 미리 받아와서 캐싱해 놓으면, 새로운 데이터를 받기 전에 사용자가 캐싱된 데이터를 볼 수 있다.

  • initialData는 이미 존재하는 캐시 데이터를 재사용하지만, prefetching은 실제 API 호출을 미리 수행하는 것이다.

ex) 페이지네이션, 무한 스크롤

prefetchQuery

const prefetchNextPosts = async (nextPage: number) => {
  const queryClient = useQueryClient();
  // 해당 쿼리의 결과는 일반 쿼리들처럼 캐싱 된다.
  await queryClient.prefetchQuery({
    queryKey: ["posts", nextPage],
    queryFn: () => fetchPosts(nextPage),
    // ...options
  });
};

// 단순 예
useEffect(() => {
  const nextPage = currentPage + 1;

  if (nextPage < maxPage) {
    prefetchNextPosts(nextPage);
  }
}, [currentPage]);

prefetchInfiniteQuery

  • Infinite Queries도 prefecth 할 수 있다.
    기본적으로 쿼리의 첫번째 페이지만 prefetch 되며, 그 이상을 prefecth 하려면 pages 옵션을 활용해야 한다. 이 경우에는 getNextPageParam 함수를 무조건 제공해 줘야 한다.
const prefetchTodos = async () => {
  await queryClient.prefetchInfiniteQuery({
    queryKey: ["projects"],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
    pages: 3, // prefetch the first 3 pages
  });
};

Infinite Queries

무한 쿼리는 무한 스크롤이나 더보기 같이 특정 조건에서 데이터를 추가적으로 받아오는 기능을 구현할 때 사용하면 유용하다. 자세한 내용

useInfiniteQuery

  1. initialPageParam이 queryFn의 첫 pageParam 값
  2. queryFn 실행하여 데이터 fetch
  3. getNextPageParam 실행하여 다음 pageParam 계산
  4. fetchNextPage 호출 시 계산된 pageParam으로 다시 queryFn 실행
  5. 2~4 과정 반복
import { useInfiniteQuery } from "@tanstack/react-query";

// 페이지 파라미터를 받는 fetch 함수
// useInfiniteQuery의 queryFn의 매개변수는 `pageParam`이라는 프로퍼티를 가질 수 있다.
const fetchColors = async ({
  pageParam, // 현재 페이지 번호를 받음
}: {
  pageParam: number;
}): Promise<AxiosResponse<PaginationColors>> => {
  return await axios.get(`http://localhost:4000/colors?page=${pageParam}`);
};

const InfiniteQueries = () => {
  const { 
    data, // 페이지별 데이터 배열
    hasNextPage, // 가져올 수 있는 다음 페이지가 있을 경우 true
    isFetching, // 데이터 패칭중 여부
    isFetchingNextPage, // fetchNextPage 메서드가 다음 페이지를 가져오는 동안 true
    fetchNextPage  // 다음 페이지를 fetch 할 수 있다.
  } = useInfiniteQuery({
    queryKey: ["colors"],
    queryFn: fetchColors,
    initialPageParam: 1, // 첫 페이지를 가져올 때 사용할 기본 페이지 매개변수
    getNextPageParam: (lastPage, allPages) => { // fetch 해온 가장 최근 페이지 목록, 현재까지 가져온 모든 페이지 데이터
      return allPages.length < 4 && allPages.length + 1;
    },
    // ...
  });

  return (
    <div>
      {data?.pages.map((group, idx) => ({
        /* ... */
      }))}
      <div>
        <button disabled={!hasNextPage} onClick={() => fetchNextPage()}>
          LoadMore
        </button>
      </div>
      <div>{isFetching && !isFetchingNextPage ? "Fetching..." : null}</div>
    </div>
  );
};

💠 useMutation

기본적으로 서버에서 데이터를 get 할 때는 useQuery를 사용한다.
서버의 데이터를 post, patch, put, delete와 같이 수정하고자 하면 useMutation을 사용한다.

  • promise 형태의 response가 필요한 경우, mutateAsync를 사용해서 얻어올 수 있다.
const mutation = useMutation({
  mutationFn: createTodo, // 3. 실제 API 호출
  onMutate() { // 2. mutaion 함수가 실행되기 전에 실행된다.
    /* ... */
  },
  onSuccess(data) {
    console.log(data);
  },
  onError(err) { 
    console.log(err);
  },
  onSettled() { // try...catch...finally 구문의 finally처럼 요청이 성공하든 에러가 발생하든 상관없이 마지막에 실행된다.
    /* ... */
  },
});

const onCreateTodo = (e) => {
  e.preventDefault();
  mutation.mutate({ title }); // 1. 
  // 이떄 title은 createTodo(mutationFn) 함수로 전달된다.
};

쿼리 무효화

ex) 게시판 목록에서 어떤 게시글을 작성 or 제거 했을 때 화면에 보여주는 게시판 목록을 실시간으로 최신화해야 할 경우
query Key가 변하지 않으므로 강제로 쿼리를 무효화하고 최신화를 진행해야 하는데, 이런 경우에 invalidateQueries() 메소드를 이용할 수 있다.
즉, query가 오래되었다는 것을 판단하고 다시 refetch를 할 때 사용한다!

import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";

const useAddSuperHeroData = () => {
  const queryClient = useQueryClient();

  return useMutation(addSuperHero, {
    onSuccess(data) {
      queryClient.invalidateQueries({ queryKey: ["super-heroes"] }); // 이 key에 해당하는 쿼리가 무효화!
      // queryKey에 "super-heroes"를 포함하는 모든 쿼리가 무효화된다.
      console.log(data);
    },
    onError(err) {
      console.log(err);
    },
  });
};

Optimistic Update

낙관적 업데이트란 서버 업데이트 시 UI에서도 어차피 업데이트할 것이라고낙관적인 가정해서 미리 UI를 업데이트 시켜주고, 서버를 통해 검증받고 업데이트 or 롤백하는 방식이다.
ex) 좋아요 버튼

  • setQueryData : React Query의 캐시를 직접 업데이트하는 메서드
const useAddSuperHeroData = () => {
  const queryClient = useQueryClient();
  return useMutation({
    mutateFn: addSuperHero,
    onMutate: async (newHero: any) => {
      // 진행 중인 요청 취소
      await queryClient.cancelQueries(["super-heroes"]); // 쿼리를 수동으로 취소

      // 이전 값(현재 상태 백업)
      const previousHeroData = queryClient.getQueryData(["super-heroes"]);

      // 새로운 값으로 낙관적 업데이트 진행
      // UI 즉시 업데이트 (서버 응답 대기 없이)
      queryClient.setQueryData(["super-heroes"], (oldData: any) => {
        return {
          ...oldData,
          data: [
            ...oldData.data,
            { ...newHero, id: oldData?.data?.length + 1 },
          ],
        };
      });

      // 값이 들어있는 context 객체를 반환 (롤백을 위해 이전 데이터 반환)
      return { previousHeroData };
    },
    // mutation이 실패하면 onMutate에서 반환된 context를 사용하여 롤백 진행(에러 발생 시 이전 상태로 복원)
    onError(error, hero, context: any) {
      queryClient.setQueryData(["super-heroes"], context.previousHeroData);
    },
    // 오류 또는 성공 후에는 항상 refetch
    onSettled() {
      queryClient.invalidateQueries(["super-heroes"]);
    },
  });
};

💠 useQueryErrorResetBoundary

react-error-boundaryuseQueryErrorResetBoundary를 결압해 선언적으로 에러가 발생했을 때 Fallback UI를 보여줄 수 있다.

  1. QueryErrorBoundary 컴포넌트 내부에 useQueryErrorResetBoundary 훅을 호출해 reset 함수를 가져온다.
  • (버튼 클릭 시) 모든 쿼리 에러를 초기화한다.
import { useQueryErrorResetBoundary } from "@tanstack/react-query"; // (*)
import { ErrorBoundary } from "react-error-boundary"; // (*)

interface Props {
  children: React.ReactNode;
}

const QueryErrorBoundary = ({ children }: Props) => {
  const { reset } = useQueryErrorResetBoundary(); // (*)

  return (
    <ErrorBoundary
      onReset={reset}
      fallbackRender={({ resetErrorBoundary }) => (
        <div>
          Error!!
          <button onClick={() => resetErrorBoundary()}>Try again</button>
        </div>
      )}
    >
      {children}
    </ErrorBoundary>
  );
};

export default QueryErrorBoundary;
  1. App.js에다 QueryErrorBoundary 컴포넌트를 추가한다.
  • queryClient 옵션에 {throwOnError: true}를 추가해야 한다.
import { QueryClientProvider, QueryClient } from "@tanstack/react-query";
import QueryErrorBoundary from "./components/ErrorBoundary"; // (*)

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      throwOnError: true, // (*) 여기서는 글로벌로 세팅했지만, 개별 쿼리로 세팅 가능
    },
  },
});

function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <QueryErrorBoundary>{/* 하위 컴포넌트들 */}</QueryErrorBoundary>
    </QueryClientProvider>
  );
}

💠 Suspense

💠 Default Query Function

💠 React Query Typescript

참고

react-query-tutorial 깃허브

profile
Hi, I am HANI Developer(╹◡╹). .....1hani me?

0개의 댓글