[TIL/React] 2024/09/04

원민관·2024년 9월 4일
0

[TIL]

목록 보기
154/159
post-thumbnail

reference: https://tanstack.com/query/latest/docs/framework/react/reference/useQueries#memoization

✅ API Reference - useQueries

예제 코드 ✍️

공식문서의 내용을, gpt를 통해 예제 코드에 적용해봤다.

import React from "react";
import { useQueries } from "@tanstack/react-query";
import axios from "axios";

// 1. fetchPost Fn: axios를 사용하여 특정 id에 해당하는 게시물 데이터를 가져옴 O
const fetchPost = async (id) => {
  const { data } = await axios.get(
    `https://jsonplaceholder.typicode.com/posts/${id}`
  );
  return data;
};

const App = () => {
  // 가져올 게시물의 id 배열 O
  const ids = [1, 2, 3, 4, 5];

  // 2. useQueries: 여러 개의 쿼리를 동시에 실행하여 각각의 게시물을 가져옴 O
  const results = useQueries({
    // 2-1. queries(key): queryKey, queryFn 등의 옵션 객체로 구성된 배열(value) O
    // Note 1: 쿼리 클라이언트 옵션은 제외됨, Provider를 통해 전달할 수 있기 때문 O
    queries: ids.map((id) => ({
      // 각 쿼리의 고유 key와 id O
      queryKey: ["post", id],
      // 데이터 fetch Fn O
      queryFn: () => fetchPost(id),
      // 데이터가 무효화되지 않도록 설정 O
      staleTime: Infinity,
      // 데이터를 로드하기 전의 placeholder 데이터 O
      // Note 2: placeholderData option은 useQueries에도 존재하지만, useQuery와 달리 각 렌더링마다 쿼리 수가 달라질 수 있기 때문에, useQuery처럼 이전 렌더링에서 전달된 정보를 사용하지는 않음 V
      placeholderData: {
        id,
        title: "Loading...",
        body: "Loading post content...",
      },
    })),
    // 2-2. combine(key): 쿼리 결과를 하나의 객체로 결합하는 함수(value) O
    combine: (results) => {
      return {
        // 각 쿼리의 데이터를 결합 O
        data: results.map((result) => result.data),
        // 쿼리 중 하나라도 pending 상태라면 true O
        pending: results.some((result) => result.isPending),
      };
    },
  });

  // 결합된 결과에서 데이터와 pending 상태를 추출 O
  // Note 3: 'results'는 data와 pending 속성을 가진 객체가 되고, 다른 쿼리 결과 속성은 손실된다. O
  const { data, pending } = results;

  if (pending) return <div>Loading...</div>;

  // Note 4: combine function은 참조가 변경되거나 쿼리 결과가 변경될 때만 다시 실행됨. 인라인으로 정의된 combine function은 매 렌더링마다 실행되므로, useCallback으로 memoization 하는 것이 좋음 O

  console.log(results);
  return (
    <div
      style={{
        maxWidth: "600px",
        display: "flex",
        flexDirection: "column",
        margin: "0 auto",
        rowGap: "20px",
      }}
    >
      <h1>Posts</h1>

      {data.map((post) => (
        <div
          style={{
            border: "1px solid black",
            borderRadius: "8px",
            backgroundColor: "black",
            color: "white",
            padding: "20px 10px",
          }}
        >
          <li key={post.id}>
            <strong>{post.title}</strong>
            <p>{post.body}</p>
          </li>
        </div>
      ))}
    </div>
  );
};

export default App;

// Note 2에서 '이전 렌더링 데이터 상태를 기반' 부분이 뭔소린지 모르겠음

✅ API Reference - useInfiniteQuery

예제 코드 ✍️

import React from "react";
import { useInfiniteQuery } from "@tanstack/react-query";

// 페이지 데이터를 가져오는 함수 (API 호출)
const fetchPosts = async ({ pageParam = 1 }) => {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=10`
  );
  if (!response.ok) {
    throw new Error("네트워크 응답이 올바르지 않습니다.");
  }
  return response.json();
};

const Posts = () => {
  //  1. Returns: useInfiniteQuery의 반환 속성들은 useQuery 훅과 동일하지만, 다음과 같은 추가 속성들이 있으며 isRefetching과 isRefetchError에는 약간의 차이가 있다.

  // 1-1. data.pages: 모든 페이지를 포함하는 배열

  // 1-2. data.pageParams: 모든 페이지 파라미터를 포함하는 배열

  // 1-3. isFetchingNextPage: fetchNextPage로 다음 페이지를 가져오는 중일 때 true

  // 1-4. isFetchingPreviosPage: fetchPreviosPage로 이전 페이지를 가져오는 중일 때 true

  // 1-5. fetchNextPage: 다음 페이지의 결과를 가져오는 함수, options.cancelRefetch를 true로 설정하면 fetchNextPage를 반복 호출할 때마다 queryFn이 매번 호출되며 이전 호출 결과는 무시된다. false로 설정하면 첫 번째 호출이 완료될 때까지 반복 호출이 무시된다. 기본값은 true

  // 1-6. fetchPreviosPage: 이전 페이지의 결과를 가져오는 함수, options.cancelRefetch는 fetchNextPage의 경우와 동일함

  // 1-7. hasNextPage: getNextPageParam 옵션을 통해 다음 페이지를 가져올 수 있을 때 true

  // 1-8. hasPreviousPage: getPreviousPageParam 옵션을 통해 이전 페이지를 가져올 수 있을 때 true

  // 1-9. isFetchNextPageError: 다음 페이지를 가져오는 동안 쿼리가 실패하면 true

  // 1-10. isFetchPreviousPageError: 이전 페이지를 가져오는 동안 쿼리가 실패하면 true

  // 1-11. isRefetching: 백그라운드에서 리패칭이 진행 중일 때 true. 초기 로딩이나 다음/이전 페이지를 가져오는 경우는 포함되지 않는다. isFetching && !isPending && !isFetchingNextPage && !isFetchingPreviousPage와 동일한 조건임.

  // 1-12. isRefetchError: 리패칭하는 동안 쿼리가 실패하면 true
  const {
    data,
    fetchNextPage,
    fetchPreviousPage,
    hasNextPage,
    hasPreviousPage,
    isFetchingNextPage,
    isFetchingPreviousPage,
    isFetching,
    isError,
    error,
    isRefetching,
    isRefetchError,
  } = useInfiniteQuery(
    //  2. Options

    // 2-1. queryFn: Required

    // 2-2. initialPageParam: Required, 첫 번째 페이지를 가져올 때 사용할 기본 페이지 파라미터

    // 2-3. getNextPageParam: Required, 새로운 데이터가 쿼리로 수신될 때, 데이터의 마지막 페이지와 전체 페이지 배열, 그리고 페이지 파라미터 정보를 받는다. 다음 페이지가 없음을 나타내기 위해 undefined 또는 null을 반환함.

    // 2-4. getPreviousPageParam: 새로운 데이터가 쿼리로 수신될 때, 데이터의 첫 번째 페이지와 전체 페이지 배열, 그리고 페이지 파라미터 정보를 받는다. 이전 페이지가 없음을 나타내기 위해 undefined 또는 null을 반환함.

    // 2-5. maxPages: 무한 쿼리 데이터에 저장할 최대 페이지 수, 최대 페이지 수에 도달하면, 새로운 페이지를 가져올 때 지정된 방향에 따라 pages 배열에서 첫 번째 또는 마지막 페이지가 제거됨. undefined 또는 0으로 설정되면 페이지 수는 무제한이며, maxPages 값이 0보다 클 경우, 필요한 경우 양방향으로 페이지를 가져올 수 있도록 getNextPageParam 및 getPreviousPageParam이 올바르게 정의되어야 함.
    {
      queryKey: ["posts"],
      queryFn: fetchPosts,
      initialPageParam: 1,
      getNextPageParam: (lastPage, allPages) => {
        console.log("getNextPageParam:", lastPage, allPages);
        return lastPage.length === 10 ? allPages.length + 1 : null;
      },
      getPreviousPageParam: (firstPage, allPages) => {
        console.log("getPreviousPageParam:", firstPage, allPages);
        return allPages.length > 1 ? allPages.length - 1 : null;
      },
      maxPages: 10,
    }
  );

  console.log("모든 페이지에 대한 배열:", data?.pages);
  console.log("페이지 params:", data?.pageParams);
  console.log("가져오는 중?:", isFetching);
  console.log("에러 발생?:", isError);
  console.log("에러가 뭐임?:", error);
  console.log("다음 페이지 가져오는 중:", isFetchingNextPage);
  console.log("이전 페이지 가져오는 중:", isFetchingPreviousPage);
  console.log("다음 페이지 있음?:", hasNextPage);
  console.log("이전 페이지 있음?:", hasPreviousPage);
  console.log("리패치 중?:", isRefetching);
  console.log("리패치 중 에러 발생?:", isRefetchError);

  return (
    <div style={{ padding: "20px", fontFamily: "Arial, sans-serif" }}>
      <h1 style={{ textAlign: "center", color: "#333" }}>Posts</h1>
      {isError && <p style={{ color: "red" }}>Error: {error.message}</p>}
      {data?.pages.map((page, pageIndex) => (
        <div key={pageIndex} style={{ marginBottom: "20px" }}>
          {page.map((post) => (
            <div
              key={post.id}
              style={{
                padding: "10px",
                border: "1px solid #ddd",
                borderRadius: "5px",
                marginBottom: "10px",
                backgroundColor: "#f9f9f9",
              }}
            >
              <h3 style={{ margin: "0 0 10px", color: "#007bff" }}>
                {post.id}:{post.title}
              </h3>
              <p style={{ margin: "0", color: "#555" }}>{post.body}</p>
            </div>
          ))}
        </div>
      ))}

      <div
        style={{
          display: "flex",
          justifyContent: "space-between",
          marginTop: "20px",
        }}
      >
        {/* 이전 페이지 버튼 */}
        {hasPreviousPage && (
          <button
            onClick={() => fetchPreviousPage()}
            disabled={isFetchingPreviousPage}
            style={{
              padding: "10px 20px",
              backgroundColor: "#007bff",
              color: "#fff",
              border: "none",
              borderRadius: "5px",
              cursor: "pointer",
              opacity: isFetchingPreviousPage ? 0.7 : 1,
            }}
          >
            {isFetchingPreviousPage ? "로딩 중..." : "이전 페이지"}
          </button>
        )}

        {/* 다음 페이지 버튼 */}
        {hasNextPage && (
          <button
            onClick={() => fetchNextPage()}
            disabled={isFetchingNextPage}
            style={{
              padding: "10px 20px",
              backgroundColor: "#28a745",
              color: "#fff",
              border: "none",
              borderRadius: "5px",
              cursor: "pointer",
              opacity: isFetchingNextPage ? 0.7 : 1,
            }}
          >
            {isFetchingNextPage ? "로딩 중..." : "다음 페이지"}
          </button>
        )}
      </div>
    </div>
  );
};

export default Posts;

그런데 예제가 잘못됐다. 페이지 파라미터가 비정상적으로 증가하거나 감소하는 현상이 발생했고, 애초에 Infinite에 대한 예시라고 볼 수도 없었다.

수정된 코드 ✍️

import React, { useEffect } from "react";
import { useInfiniteQuery } from "@tanstack/react-query";
import { useInView } from "react-intersection-observer";

// 페이지 데이터를 가져오는 함수 (API 호출)
const fetchPosts = async ({ pageParam = 1 }) => {
  const LIMIT = 10;
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=${LIMIT}`
  );

  // 네트워크 응답이 실패한 경우 오류를 발생시킴
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }

  // JSON 데이터로 변환
  const data = await response.json();

  // 반환값을 콘솔에 출력
  console.log("fetchPosts 반환값:", {
    data,
    nextPage: data.length === LIMIT ? pageParam + 1 : null,
    prevPage: pageParam > 1 ? pageParam - 1 : null,
  });

  return {
    data,
    nextPage: data.length === LIMIT ? pageParam + 1 : null,
    prevPage: pageParam > 1 ? pageParam - 1 : null,
  };
};

export default function App() {
  const {
    data,
    error,
    status,
    fetchNextPage,
    fetchPreviousPage,
    isFetchingNextPage,
    isFetchingPreviousPage,
    hasNextPage,
    hasPreviousPage,
  } = useInfiniteQuery({
    queryKey: ["posts"],
    queryFn: fetchPosts,
    getNextPageParam: (lastPage) => {
      // getNextPageParam에서 호출된 lastPage 확인
      console.log("getNextPageParam 호출:", lastPage);
      return lastPage.nextPage;
    },
    getPreviousPageParam: (firstPage) => {
      // getPreviousPageParam에서 호출된 firstPage 확인
      console.log("getPreviousPageParam 호출:", firstPage);
      return firstPage.prevPage;
    },
  });

  const { ref, inView } = useInView();

  useEffect(() => {
    if (inView && hasNextPage && !isFetchingNextPage) {
      fetchNextPage();
    }
  }, [inView, fetchNextPage, isFetchingNextPage, hasNextPage]);

  useEffect(() => {
    if (inView && hasPreviousPage && !isFetchingPreviousPage) {
      fetchPreviousPage();
    }
  }, [inView, fetchPreviousPage, isFetchingPreviousPage, hasPreviousPage]);

  const neumorphismStyle = {
    background: "#e0e0e0",
    borderRadius: "12px",
    boxShadow:
      "8px 8px 16px rgba(0, 0, 0, 0.1), -8px -8px 16px rgba(255, 255, 255, 0.9)",
    padding: "16px",
    margin: "8px 0",
    transition: "box-shadow 0.3s ease-in-out",
  };

  const loaderStyle = {
    border: "4px solid #f3f3f3",
    borderTop: "4px solid #3498db",
    borderRadius: "50%",
    width: "40px",
    height: "40px",
    animation: "spin 1s linear infinite",
    margin: "0 auto",
    display: "block",
  };

  return (
    <div
      style={{
        padding: "16px",
        maxWidth: "600px",
        margin: "0 auto",
      }}
    >
      <h1>Posts</h1>
      <style>
        {`
          @keyframes spin {
            0% { transform: rotate(0deg); }
            100% { transform: rotate(360deg); }
          }
        `}
      </style>

      {status === "loading" ? (
        <div style={loaderStyle}></div>
      ) : status === "error" ? (
        <div>{error.message}</div>
      ) : (
        <>
          {data?.pages.map((page, pageIndex) => (
            <div key={pageIndex}>
              {page.data.map((post) => (
                <div key={post.id} style={neumorphismStyle}>
                  <h3
                    style={{
                      fontSize: "18px",
                      fontWeight: "bold",
                      color: "#333",
                    }}
                  >
                    {post.title}
                  </h3>
                  <p style={{ color: "#666" }}>{post.body}</p>
                </div>
              ))}
            </div>
          ))}

          <div ref={ref} style={{ textAlign: "center", padding: "16px" }}>
            {isFetchingNextPage || isFetchingPreviousPage ? (
              <div style={loaderStyle}></div>
            ) : null}
          </div>
        </>
      )}
    </div>
  );
}

react-intersection-observer에서 가져온 useInView의 return 값인 viewport의 값을 활용하게 된다.

viewport와 next or previous page, 그리고 fetching 여부를 점검하여 fetchNextPage 또는 fetchPreviousPage 함수를 실행한다.

options.cancelRefetch에 의해 queryFn을 실행하게 된다. queryFn에 엮여있는 fetchPosts 함수가 pageParams의 증감을 주관한다.

const fetchPosts = async ({ pageParam = 1 }) => {
  const LIMIT = 10;
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts?_page=${pageParam}&_limit=${LIMIT}`
  );

  // 네트워크 응답이 실패한 경우 오류를 발생시킴
  if (!response.ok) {
    throw new Error("Network response was not ok");
  }

  // JSON 데이터로 변환
  const data = await response.json();

  console.log("데이터:", data);

  // 반환값을 콘솔에 출력
  console.log("fetchPosts 반환값:", {
    data,
    nextPage: data.length === LIMIT ? pageParam + 1 : null,
    prevPage: pageParam > 1 ? pageParam - 1 : null,
  });

  return {
    data,
    nextPage: data.length === LIMIT ? pageParam + 1 : null,
    prevPage: pageParam > 1 ? pageParam - 1 : null,
  };
};

이어서 다음 두 함수를 통해 params 정보를 얻는다.

최종적으로 다음 useInfiniteQuery의 return 값으로 활용된다.

profile
Write a little every day, without hope, without despair ✍️

0개의 댓글