[TanStack Query] Query Cancellation, Optimistic Updates

Query Cancellation

불필요한 네트워크 요청을 제거하기 위해 사용

대용량 fetching을 중간에 취소하거나, 사용하지 않는 컴포넌트에서 fetching이 진행중인 경우 자동으로 취소시켜 불필요한 네트워크 비용을 줄일 수 있음

 

QueryFunctionContext

 queryFn이 매개변수로 받는 객체

export const getTodos = async (queryFnContext) => {
  const { queryKey, pageParam, signal, meta } = queryFnContext;
	// queryKey: 배열형태의 쿼리키
	// pageParam: useInfiniteQuery 사용 시 getNextPageParam 실행 시 적용
	// signal: AbortSignal 을 의미 (네트워크 요청을 중간에 중단시킬 수 있는 장치)
	// meta: query에 대한 정보를 추가적으로 메모를 남길 수 있는 string 필드

  const response = await axios.get("http://localhost:5000/todos", { signal });
  return response.data;
};

useQuery({
  queryKey: ["todos"],
  queryFn: getTodos,
})
// example: <div onClick={(event) => {}}

 

컴포넌트 unmount시 Query 취소하기

API 요청은 기본적으로 컴포넌트가 언마운트되어도 중단되지 않음

 

1) GET 요청시 abort signal이 옵션으로 들어간 경우만 자동으로 취소

import axios from 'axios'

const query = useQuery({
  queryKey: ['todos'],
  queryFn: ({ signal }) =>
    axios.get('/todos', {
      // Pass the signal to `axios`
      signal,
    }),
})

- 여기서 signal은 useQuery 훅에서 자동으로 관리되므로, 우리가 따로 컨트롤할 필요가 없음

- 우리는 axios에 '/todos'에서 get할건데 이 옵션을 추가로 줄게~ 하고 전달해주면 된다

- 그럼 컴포넌트가 언마운트되거나 요청을 취소할 필요가 있을 때 useQuery가 시그널을 보내 요청을 중단!

 

 

2) 특정 이벤트 발생시 취소시키기 (수동 취소)

const query = useQuery({
  queryKey: ['todos'],
  queryFn: async ({ signal }) => {
    const resp = await fetch('/todos', { signal })
    return resp.json()
  },
})

const queryClient = useQueryClient()

return (
  <button
    onClick={(e) => {
      e.preventDefault()
      queryClient.cancelQueries({ queryKey: ['todos'] })
    }}
  >
    Cancel
  </button>
)

- queryClient.cancelQueries() 함수를 호출해 쿼리를 직접적으로 취소할 수 있음

- 큰 데이터의 fetch 작업을 다 끝내지 않고 취소하고 싶을 때 버튼을 눌러 중단할 수 있게 됨

 

모든 GET 요청마다 Abort Signal을 심어야할까?

단순히 모든 GET 요청에 Abort Signal을 심는 것은 작업 부하를 올리고 바람직하지 않음

동영상 다운로드같은 대용량 fetching이 아닌 이상 대부분의 GET 요청은 빠르게 완료/캐싱처리 되어 성능에 유의미한 영향을 끼치지 않음!!

대용량 fetching 또는 Optimistic UI를 구현해야할 때처럼 필요한 경우에만 적용하는 것을 권장함

 

 

Optimistic Updates (낙관적 업데이트)

서버 요청이 정상임을 가정하고 서버와 정확히 상태를 연동하는 대신 UI를 먼저 변경하여, 더 나은 UX를 제공할 수 있는 방법

혹시라도 서버 요청이 실패하는 경우 UI를 원상복구

ex. 인스타 좋아요 같은 경우 실제 서버에 좋아요 수를 업데이트하는 요청을 보내고, 최신상태를 받아와 UI에 표시하는 대신 업데이트하는 요청은 요청대로 보내두고 UI만 미리 변경해둔다 만약 에러가 발생해 좋아요 처리가 제대로 되지 않는 경우 나중에 변경된 UI를 되돌린다

 

https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates

 

Optimistic Updates | TanStack Query React Docs

React Query provides two ways to optimistically update your UI before a mutation has completed. You can either use the onMutate option to update your cache directly, or leverage the returned variables to update your UI from the useMutation result. Via the

tanstack.com

 

예시코드

const addMutation = useMutation({
    mutationFn: addTodo,
    // onSuccess: () => {
    //   queryClient.invalidateQueries(["todos"]);
    // },
    onMutate: async (newTodo) => {
      console.log("onMutate 호출");
      await queryClient.cancelQueries({ queryKey: ["todos"] });

      const previousTodos = queryClient.getQueryData(["todos"]);

      queryClient.setQueryData(["todos"], (old) => [...old, newTodo]);

      return { previousTodos };
    },
    onError: (err, newTodo, context) => {
      console.log("onError");
      console.log("context:", context);
      queryClient.setQueryData(["todos"], context.previousTodos);
    },
    onSettled: () => {
      console.log("onSettled");
      queryClient.invalidateQueries({ queryKey: ["todos"] });
    },
  });
  
  // todoList.js
  export const addTodo = (newTodo) => {
  console.log("addTodo 호출");

  return axios.post("http://localhost:5000/todos", newTodo);
};

1) useMutation: 데이터를 추가하거나 수정하는 비동기작업을 처리하는 hook

 

2) onMutate: API 요청이 보내지기 직전에 실행, 낙관적 업데이트를 처리할 곳

- 요청 취소: queryClient.cancelQueries()로 현재 진행중인 todos 관련 쿼리를 취소

- 캐싱된 데이터 저장: previousTodos에 현재 todos 데이터를 저장하여 나중에 요청이 실패했을 때를 대비한 데이터를 백업

- UI 업데이트: queryClient.setQueryData를 사용해 todos에 새로 추가할 newTodo 항목을 미리 반영해 UI를 업데이트, 서버 응답을 기다리지 않고 새로운 할 일이 즉시 화면에 나타남

 

3) onError: 요청이 실패할 경우 실행

- context.previousTodos를 사용해 이전 상태로 복구

- onMutate에서 반환한 객체가 context에 저장되므로, previousTodos를 context에서 꺼내 사용할 수 있음!

 

4) onSettled: 요청이 성공하거나 실패했을 때 항상 실행

- queryClient.invalidateQuries를 호출해 기존 캐시를 무효화하고 서버에서 최신 데이터를 다시 가져옴

 

* 결국 onSettled로 서버 데이터를 반영하면 요청이 실패하더라도 이전 값으로 되돌릴 수 있는건데 왜 onError 처리를 해주는걸까?

- onError: 요청 실패 즉시 UI를 빠르게 원래 상태로 되돌리는 역할

- onSettled: 최종적으로 서버 데이터를 동기화하는 역할, 이 과정에서 서버 응답에 시간이 걸릴 수 있고 사용자에게 즉각적인 반응을 주지 못해 혼란을 줄 수 있음

- 만약 네트워크 오류로 실패한 경우에 onError마저 없다면 onSettled로 이전 데이터로 복원하는 것 또한 제대로 동작하지 않을 것

 

* 그럼 UI 성공/실패에따라 UI에 잘 반영되는데 onSettled는 왜 필요할까?

- 캐시를 직접 수정한 후에도 서버와 최종 상태를 동기화해야 함!

- 서버에 다른 사용자에 의해 변경된 데이터가 있을 수 있는 상황에서 캐시 데이터만 활용하면 일관성이 깨질 수 있음

 

Prefetching

특정 데이터를 백그라운드에서 가져올 수 있는 기능

페이지 이동 전에 이동할 페이지의 쿼리를 백그라운드에서 미리 호출 -> 캐시된 데이터가 있는 상태로 해당 페이지에 이동해 로딩 없이 바로 UI를 확인할 수 있게 함

 

const prefetchTodos = async () => {
  // The results of this query will be cached like a normal query
  // prefetch 할 queryKey와 queryFn 은 이동할 페이지의 쿼리와 동일해야 적절합니다.
  await queryClient.prefetchQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })
}

 

- queryClient.prefetchQuery(): 데이터를 미리 가져와 캐시에 저장해두는 기능, 이동할 페이지에 같은 키의 쿼리를 사용하면 그 키에 미리 캐시되어있어 로드 없이 사용할 수 있다는 의미다

- 이전 페이지에서 prefetchQuery로 미리 캐싱, 이동한 페이지에서 useQuery로 저장소에 캐시되어있는 데이터 바로 사용

 

예시코드 - TMDB에서 영화 데이터를 첫페이지만 미리 가져오기

// Header.jsx
export default function Header() {
  const { pathname } = useLocation();

  const queryClient = useQueryClient();
  const onMouseOver = () => {
    if (pathname !== "/") return;

    queryClient.prefetchQuery({
      queryKey: ["movies", 1],
      queryFn: fetchMovieData,
    });
  };
  return (
    <div>
      <Link to={"/pagination"} onMouseOver={onMouseOver}>
        페이지네이션
      </Link>
      // ...생략
    </div>
  );
}

// movies.js
export async function fetchMovieData({ queryKey, pageParam = 1 }) {
  console.log("fetchMovieData 호출");
  
  // queryKey 에서 page 추출
  const [_, page] = queryKey;
  const options = {
    method: "GET",
    headers: {
      accept: "application/json",
      Authorization:
        "인증키",
    },
  };
  
  const response = await fetch(
    `https://api.themoviedb.org/3/movie/top_rated?language=en-US&page=${page}&include_adult=false`,
    options
  );

  return response.json();
}

1) 특정 페이지로 이동하는 Link 태그 위에 MouseOver 이벤트 설정

- queryClient를 받아와 prefetchQuery 사용해 1페이지를 미리 받아둠

 

// MoviePagination.jsx
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { fetchMovieData } from "../api/movie";
import Pagination from "../components/Pagination";

export default function MoviePagination() {
  // TMDB 에서 영화정보 가져와서 페이지네이션 적용하기
  const [page, setPage] = useState(1);
  const { data: movies, isLoading } = useQuery({
    queryKey: ["movies", page], // initial queryKey:["movie", 1]
    queryFn: fetchMovieData,
    select: ({ total_pages, results }) => ({
      total_pages,
      results,
    }),
    keepPreviousData: true,  // 다음 paginated 관련
  });
  console.log("movies:", movies);

  return (
    <div>
      {isLoading ? (
        <h2>로딩중...</h2>
      ) : (
        <ul>
          {movies?.results?.map((movie) => (
            <li key={movie.id}>{movie.title}</li>
          ))}
        </ul>
      )}
    </div>
  );
}

2) prefetch한 데이터를 보여줄 페이지에서 데이터 사용

- page의 초기값을 1로 해두고 이 페이지에 들어오므로, prefetch 해둔 queryKey ["movies", 1]를 로딩없이 바로 사용할 수 있음!

 

 

Paginated / Lagged Queries

다른 페이지를 클릭했을 때 매번 새로운 Loading UI를 보여주기보다 기존 UI를 유지하다가 서버 데이터를 받아왔을 때 바꾸는 방식을 적용할 수 있음

useQuery의 옵션 중 keepPreviousData를 true로 바꾸면 이전 캐시데이터를 기반으로 isLoading 여부를 판단하게 함

 

https://tanstack.com/query/latest/docs/framework/react/guides/paginated-queries

 

Paginated / Lagged Queries | TanStack Query React Docs

Rendering paginated data is a very common UI pattern and in TanStack Query, it "just works" by including the page information in the query key: const result = useQuery({ queryKey: ['projects', page], queryFn: fetchProjects, }) const result = useQuery({ que

tanstack.com

 

예시코드

// movie.js
export async function fetchMovieData({ queryKey, pageParam = 1 }) {
  console.log("fetchMovieData 호출");
  const [_, page] = queryKey;
  // useQuery 에서 사용될 때는 queryKey 에서 page 추출
  // useInfiniteQuery에서 사용될 때는 pageParam 에서 page 추출
  const pageToFetch = page ?? pageParam;  // page가 null, undefined면 pageParam 사용
  const options = {
    method: "GET",
    headers: {
      accept: "application/json",
      Authorization:
        "인증키",
    },
  };
  const response = await fetch(
    `https://api.themoviedb.org/3/movie/top_rated?language=en-US&page=${pageToFetch}&include_adult=false`,
    options
  );

  return response.json();
}

1) fetch 할 때 넘겨주는 pageToFetch를 queryKey에 page가 있을 경우 그 page로, 아닌 경우 pageParam으로 넘겨진 값을 이용해 결정

 

// MoviePagination.jsx
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { fetchMovieData } from "../api/movie";
import Pagination from "../components/Pagination";

export default function MoviePagination() {
  // TMDB 에서 영화정보 가져와서 페이지네이션 적용하기
  const [page, setPage] = useState(1);
  const { data: movies, isLoading } = useQuery({
    queryKey: ["movies", page], // initial queryKey:["movie", 1]
    queryFn: fetchMovieData,
    select: ({ total_pages, results }) => ({
      total_pages,
      results,
    }),
    keepPreviousData: true,
  });
  console.log("movies:", movies);

  const onClickPage = (selected) => {
    // 같은 페이지를 그대로 클릭시 함수종료
    if (page === selected) return;
    if (typeof selected === "number") {
      setPage(selected);
      return;
    }
    if (selected === "prev" && page > 1) {
      setPage((prev) => prev - 1);
      return;
    }
    if (selected === "next" && page < movies.total_pages) {
      setPage((prev) => prev + 1);
      return;
    }
  };

  return (
    <div>
      <h1>영화 페이지네이션 예제</h1>
      {isLoading ? (
        <h2>로딩중...</h2>
      ) : (
        <ul>
          {movies?.results?.map((movie) => (
            <li key={movie.id}>{movie.title}</li>
          ))}
        </ul>
      )}

      <Pagination
        currentPage={page}
        totalPages={movies?.total_pages ?? 1}
        onClick={onClickPage}
      />
    </div>
  );
}

- useQuery의 keepPreviousData를 true 로 설정해두어 기존에 fetch해둔 값이 있다면 (이 예제의 경우 이전 페이지의 데이터) isLoading을 false로 설정함

- 따라서 로딩중... 표시가 다음 페이지부터는 보이지 않음

- 이전 페이지의 값을 보여주고있다가 다음 페이지의 데이터가 모두 fetch되면 그 값으로 변경됨

// Pagination.jsx
export default function Pagination({ currentPage, totalPages, onClick }) {
  console.log("currentPage: ", currentPage);
  return (
    <div>
      <span className="page-click" onClick={() => onClick("prev")}>
        {"<"}
      </span>
      {Array(totalPages)
        .fill(0)
        .map((_, idx) => {
          if (idx < 5) {
            return (
              <span
                className={`page-click ${
                  currentPage === idx + 1 && "active-page"
                }`}
                key={idx}
                onClick={() => onClick(idx + 1)}
              >
                {idx + 1}
              </span>
            );
          } else if (idx >= 5 && idx + 1 === totalPages) {
            return (
              <span key={idx}>
                <span style={{ marginLeft: 5, marginRight: 5 }}>...</span>
                <span
                  className={`page-click ${
                    (currentPage === idx + 1 || currentPage === 500) &&
                    "active-page"
                  }`}
                  onClick={() => onClick(totalPages > 500 ? 500 : totalPages)}
                >
                  {/* TMDB API는 페이지 번호 요청을 최대 500까지만 할 수 있게 제한되어있음 */}
                  {totalPages > 500 ? 500 : totalPages}
                </span>
              </span>
            );
          }
        })}
      <span className="page-click" onClick={() => onClick("next")}>
        {">"}
      </span>
    </div>
  );
}

- pagination 기능을 구현해둔 컴포넌트

- 현재 페이지, 총 페이지 수, 페이지 변경에 필요한 함수를 매개변수로 전달받음

- 사용자가 페이지를 선택했을 때 그 페이지로 이동할 수 있게 함

- 1~5 페이지 번호를 보여주고, 그 이후는 ... 로 생략 + 마지막 페이지 표시

- 클릭된 페이지 번호에따라 onClick함수에서 해당 페이지로 이동시킴

 

* 유의할 점

- 빠르게 fetch될 수 있는 정도의 데이터의 경우 이 기법을 사용해 로딩되는 동안의 깜빡임을 방지할 수 있겠지만, 오래 걸리는 데이터의 fetch는 차라리 로딩 UI를 표시해 지금 데이터를 fetch하고 있음을 표시하는 것이 좋음

- 사용자가 실제 원하는 데이터가 불러와지기 전까지 이전 데이터를 보고있어야하기 때문!

 

Infinite Queries (무한 스크롤)

Data Fetching이 일어날 때마다 기존 리스트 데이터에 Fetched Data를 추가하고자할 때 유용하게 사용할 수 있는 hook

더보기 UI 또는 무한 스크롤 UI에 사용하기 적합

https://coffeeandcakeandnewjeong.tistory.com/52

 

[리액트] react-virtualized을 활용한 무한 스크롤 구현

 

coffeeandcakeandnewjeong.tistory.com

const fetchProjects = async ({ pageParam = 0 }) => {
    const res = await fetch('/api/projects?cursor=' + pageParam)
    return res.json()
  }

  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })

- useInfiniteQuery: useQuery()와 유사하지만, getNextPageParam 옵션을 지정해줘야 함

- useInfiniteQuery에선 페이지네이션의 경우와는 다르게 쿼리키에 어떤 페이지에대한 정보인지를 구분하여 저장하지 않고, 하나의 데이터로 관리함

- 하지만 서버에는 몇페이지의 데이터를 달라고 나눠서 요청해 받아오는 것이므로, 별개의 pageParams가 존재함

 

예시코드

// movie.js
export async function fetchMovieData({ queryKey, pageParam = 1 }) {
  console.log("fetchMovieData 호출");
  const [_, page] = queryKey;
  // useQuery 에서 사용될 때는 queryKey 에서 page 추출
  // useInfiniteQuery에서 사용될 때는 pageParam 에서 page 추출
  const pageToFetch = page ?? pageParam;  // page가 null, undefined면 pageParam 사용
  const options = {
    method: "GET",
    headers: {
      accept: "application/json",
      Authorization:
        "인증키",
    },
  };
  const response = await fetch(
    `https://api.themoviedb.org/3/movie/top_rated?language=en-US&page=${pageToFetch}&include_adult=false`,
    options
  );

  return response.json();
}

- 페이지네이션처럼 쿼리키에 page를 함께 보내는 것이 아니므로 page가 존재하지 않음

- useInfiniteQuery에서 관리하는 pageParam을 받아 1페이지부터 한페이지씩 받아오게됨

 

// MovieInfiniteScroll.jsx
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";
import { fetchMovieData } from "../api/movie";

export default function MovieInfiniteScroll() {
  const {
    data: movies,
    hasNextPage,
    fetchNextPage,
    isFetchingNextPage,
  } = useInfiniteQuery({
    queryKey: ["movies"],
    queryFn: fetchMovieData,
    getNextPageParam: (lastPage) => {
      console.log("getNextPageParam 호출");
      console.log("lastPage: ", lastPage);
      if (lastPage.page < lastPage.total_pages) {
        console.log("다음 페이지로 pageParam 저장");
        return lastPage.page + 1;
      }
    },
    select: (data) => {
      return data.pages.map((pageData) => pageData.results).flat();
    },
  });
  
  const { ref } = useInView({
    threshold: 1,
    onChange: (inView) => {
      if (!inView || !hasNextPage || isFetchingNextPage) return;
      fetchNextPage();
    },
  });

  return (
    <div>
      <ul style={{ marginBottom: 300 }}>
        {movies?.map((movie) => (
          <li key={movie.id}>{movie.title}</li>
        ))}
      </ul>
      <div ref={ref}>
        Trigger to Fetch Here
      </div>
    </div>
  );
}

1) 컴포넌트 렌더링시 queryFn(fetchMovieData) 호출 후 getNextPageParam 호출

- 마지막 페이지가 아닌 경우 pageParam에 마지막 페이지 + 1을 저장해둠

- lastPage는 useInfiniteQuery가 관리하여 할당해줌 (마지막에 가져온 페이지의 정보)

 

2) 옵저버 설정하기

- useInview 사용해 무한스크롤에서 다음 fetch의 트리거가 될 요소의 참조를 받아둠

- useInview는 react-intersection-observer에서 제공하는 기능 (설치, import 필요)

- threshold: 0~1사이의 값 설정하여 그 요소가 뷰포트에 어느정도 드러났을 때 fetch를 실행할건지 설정함 (0일경우 요소의 시작점이 보이기만하면 fetch, 0.3이면 30%가 보였을 때 fetch)

- onChange: 지정한 요소가 뷰포트에 들어가거나 나갈 때 호출되는 콜백함수

- onChange를 통해 뷰포트에 들어오지 않았고, 다음 페이지가 없고, 다음 페이지를 fetching 중인 경우가 아닐 때 fetchNextPage()를 호출하게 함