본문 바로가기

Study/npm Libraries

TanStack Query(React Query)

What is react-query? | official website, react-query-tutorial repo

  1. 리액트 쿼리는 리액트 어플리케이션에서 데이터 fetching과 관련된 상태를 관리해 주는 라이브러리이다(이제는 리액트 뿐만 아니라, 다른 프레임워크에서도 사용이 가능하다). 
  2. 리액트는 컴포넌트를 중심으로 ui를 렌더링하는 라이브러리이기 때문에, data fetching에 정해진 패턴이 없다. 
    • 일반적으로는 useEffect를 이용해 request를 보내고, useState를 이용해 관련된 상태(로딩중, 에러, response...)를 관리한다. 
    • 이러한 비동기/서버 관련 state는 클라이언트 측의 state(ex. theme)와는 매우 다르다. 
  3. client vs. server state 
    • 클라이언트 사이드의 상태는 어플리케이션의 메모리에 존재하며, 이 값에 접근하는 것/ 이 값을 수정하는 것이 동기적으로(synchronous) 이루어진다. 
    • 서버 측의 데이터는 어플리케이션의 외부(주로 데이터 베이스)에 존재하며, 데이터를 주고 받거나 수정하기 위해서는 비동기(asynchronous) api가 필요하다.
    • 클라이언트와 서버 양측에서 모두 데이터를 수정할 수 있다. 
    • 그렇기 때문에 서버 데이터의 상태와 클라이언트 ui 상태가 일치하지 않을 수 있다. 
    • 뿐만 아니라 caching, 똑같은 데이터에 대한 여러 요청을 막는 것(de-duplication), 오래된 데이터(stale data)를 업데이트하는 것 등을 직접 모두 다루는 것은 꽤 어려운 일이다. => 리액트 쿼리를 이용하면 이러한 비동기 상태를  쉽게 관리할 수 있다. 

useQuery | fetching data

1. useQuery vs. useEffect & useState

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
    </QueryClientProvider>
  )
}

  • useQuery와 같이 리액트 쿼리가 제공하는 모든 hooks, methods를 이용하기 위해서는, Provider(QueryClientProvider)를 제공해 주어야 한다.

useQuery(queryKey, queryFn)

  1. queryKey는 쿼리를 식별하는(identify) 유니크한 키이다.
  2. queryFn에는 promise 반환하는 함수를 전달해 주어야 한다.

export const Example = () => {
  const { isLoading, data, isError, error } = useQuery('example', () => {
    return axios.get('http://localhost:4000/example');
  });
  
  if(isLoading) return <h2>Loading...</h2>;
  
  if(isError) return <h2>{error.message}...</h2>;
  
  return (
    data?.data.map(item => <p>item</p>)
  )
};

  1. useQuery 훅의 실행 결과를 results에 저장하면, 훅이 반환하는 모든 프로퍼티를 확인할 수 있다. 
  2. 구조분해 할당을 이용해서 필요한 프로퍼티만 사용한다.
    • isLoading, data, error...
    • data.data에서 응답 데이터를 받아올 수 있다. 


리액트에서 일반적으로 api를 호출하는 방법은 아래와 같다.

// no-react query
export const SuperHeroesPage = () => {
  const [isLoading, setIsLoading] = useState(true);
  const [data, setData] = useState([]);
  const [error, setError] = useState(null);

  useEffect(() => {
    axios
      .get('http://localhost:4000/example').then((res) => {
        setData(res.data);
        setIsLoading(false);
      })
      .catch((error)=>{
        setError(error.message); 
        setIsLoading(false); 
      })
  }, []);
  
  if(error) {
    return <h2>{error}</h2>
  }

  if (isLoading) {
    return <h2>Loading...</h2>;
  }
  return <h2>Example page</h2>;
};

2. cacheTime

<cahche> In computing, a cache is a hardware or software component that stores data so that future requests for that data can be served faster.


리액트 쿼리가 제공하는 쿼리 캐시는 기본적으로 5분간 지속되며, 다음과 같은 일을 가능하게 해 준다.

  1. useQuery로 데이터를 fetching 한 페이지의 경우, 재방문시 loading 문구가 나타나지 않는 것을 볼 수 있다.
  2. 반면 일반적인 fetching을 사용한 경우, 그 페이지를 방문할 때 마다 loading 문구가 나타나는 것을 볼 수 있다.

query&amp;nbsp; cache


리액트 쿼리는 어떻게 이런 일을 할까?

  1. useQuery가 실행되면, 인자로 전달한 queryKey의 isLoading 상태가 true로 업데이트된다. 
  2. 데이터를 fetching하기 위한 요청을 보낸다. 
  3. 요청이 무사히 완료되면, useQuery에 전달한 key와 함수를 식별자(identifier)로 이용해서 데이터를 캐시한다.
  4. useQuery를 사용하는 페이지에 방문했을 때, 리액트 쿼리는 그 데이터에 해당하는 쿼리가 캐시에 존재하는지 확인한다. 만약 존재한다면, isLoading 상태를 업데이트 하는 과정 없이 캐시된 데이터가 반환된다. 
  5. 이 덕분에 후속 요청이 발생했을 때 loading 문구를 보지 않을 수 있다. 
    • 하지만 요청 자체가 일어나지 않는 것은 아니다. 서버 측의 데이터와 ui에서 보여지고 있는 데이터에 차이가 있을 수 있기 때문이다.

const { isFetching } = useQuery(queryKey, queryFn);

리액트 쿼리는 요청을 보내되, 이전의 응답과 현재 응답에 차이가 있는지를 분석해서, 차이가 있는 경우에만 새로운 데이터를 렌더링한다(background re-fetching). 이 상태를 isFetching으로 확인할 수 있다. 즉, isLoading은 업데이트 되지 않더라도(ui가 다시 렌더링 되지 않더라도), isFetching은 업데이트 된다(재요청이 발생한다). 캐시 지속 시간은 아래와 같이 직접 설정할 수도 있다.

useQuery(queryKey, queryFn, { cacheTime: 1000 * 60 * 5});

3. staleTime

query cache gif를 살펴보면, useQuery를 이용해서 데이터를 받아오는 페이지를 재방문했을 때 새롭게 화면을 렌더링하지는 않더라도 re-fetching은 일어나는것을 확인할 수 있다(네트워크 탭). stale Time은 re-fetching이 일어나지 않도록 시간을 지정할 수 있는 옵션이다. 기본값은 0이다.

useQuery(queryKey, queryFn, { staleTime: 1000 * 30});

  1. 페이지 방문 시 데이터가 30초간 fresh 상태를 유지하다가, stale 상태로 변환된다.
  2. 데이터가 fresh 상태인 30초 동안은 background re-fetching이 일어나지 않고, 지정한 시간이 지나서 stale 상태가 되어야 re-fetching이 일어난다 => isFetching === true.

4. refetchOnMount

기본값은 true이다. false, 'always' 등의 값을 지정할 수 있다.

useQuery(queryKey, queryFn, { refetchOnMount: 'always'});

  1. true, false | 컴포넌트가 마운트 될 때 마다 refetching이 일어나거나, 일어나지 않도록 만든다.
  2. 'always' | 데이터의 상태(fresh/stale)에 관계 없이 컴포넌트가 마운트 되면 refetching이 일어나도록 한다.

5. refetchOnWindowFocus

useQuery(queryKey, queryFn, { refetchOnWindowFocus: true});

  1. 기본값은 true이다. refetchOnMouse 속성과 같이 false, always를 값으로 설정할 수 있다.
  2. 일반적으로, 리액트 컴포넌트는 데이터 베이스의 데이터가 바뀐 것을 알아차릴 수 없다. 따라서 페이지를 refresh하거나, 데이터를 re-fetching 하도록 로직을 구현하지 않았다면 ui는 업데이트되지 않는다.
  3. refetchOnWindowFocus 속성(기본값 ture)은 윈도우에 포커스가 다시 생겼을 때, 데이터를 refetching 한다.
  4. 예를 들어, 사용자가 웹 어플리케이션으로 다시 돌아왔을 때 data를 다시 fetching 함으로써, ui가 데이터베이스와 동일한 상태를 보여줄 수 있도록 한다.

6. polling

<polling>In network, Poll the network to retrieve information from network devices that you can use to monitor the behavior of the devices. About polling the network. To poll the network, Network Manager periodically sends queries to the devices on the network.
+) polling이란 일정한 시간 간격을 두고 데이터를 fetching하는 것을 의미한다. => ex) stock marcket data needs to be refetched by every seconds.

useQuery(queryKey, queryFn, 
  { 
    refetchInterval: 1000,
    refetchIntervalInBackground: false,
  }
);

  1. 기본값은 false이다.
  2. ms 단위의 숫자를 값으로 전달할 수 있다.
  3. 단, 윈도우가 focus를 잃었을 때에는 interval refetching이 일어나지 않는다. => refetchIntervalInBackground를 true로 설정하면 refetching이 가능하다.

7. refetch | useQuery with Event

export const Example = () => {
  const { isLoading, isFetching, data, isError, error, refetch } = useQuery(
    'heroes',
    () => {
      return axios.get('http://localhost:4000/superheroes');
    },
    {
      enabled: false,
    }
  );
  
  // ...
  return (
    <>
      <button type='button' onClick={refetch}>
        fetching
      </button>
      {(isLoading || isFetching) && <h2>loading...</h2>}
      {isError && <h2>{error.message}</h2>}
      {data &&
        data.data.map((item) => (
          <p>{item.name}</p>
        ))}
    </>
  );
}

  1. enabled: false 
    • 이벤트가 발생했을 때에만 요청을 보내도록, 컴포넌트가 마운트 되었을 때에는 요청을 보내지 않게 한다. 
  2. refetch 
    • 이벤트에 refetch를 콜백 함수로 전달함으로써, 우리가 원하는 이벤트가 발생하는 시점에 query를 발생(trigger)시킬 수 있다. 
    • 이 요청에 대해서도 cache와 staleTime은 동일한 역할을 한다. 

8. onSuccess, onError

데이터 요청이 성공했을 때/ 실패했을 때 실행할 콜백함수를 정의해서 전달할 수 있다.

const onSuccess = (data) => {
    showModal('success!')
}
const onError = (error) => {
    showModal('error!')
}

useQuery(queryKey, queryFn, { onSuccess, onError });

  • useQuery에 정의한 콜백 함수를 전달하면, 자동으로 data와 error 객체가 콜백 함수의 인자로 전달된다.

9. select

select 프로퍼티에 함수를 전달하면, 그 함수 안에서 response 데이터를 가공한 뒤 data로 반환할 수 있다.

export const Example = () => {
  const { data } = useQuery(
    'heroes',
    () => {
      return axios.get('http://localhost:4000/superheroes');
    },
    {
      select: (data) => {
        return data.data.map((hero) => hero.name);
      },
    }
  );
  
  // ...
  return (
    <>
      // {data.data.map((item) => (<p>{item.name}</p>))}
      {data.map((heroName) => (<p>{heroName}</p>))}
    </>
  );
}

custom query

1. basic

// useHeroData.js
const useHeroData = (props) => { // onSuccess, onError를 인자로 전달받게끔 사용할 수도 있다
  const getSuperHeroes = () => {
    return axios.get('http://localhost:4000/superheroes');
  };

  return useQuery('hero', getSuperHeroes, {
    select: (data) => {
      console.log(data);
      return data.data.map((hero) => hero.name);
    },
  });
};

export default useHeroData;

// example 
const { data } = useHeroData();

2. queryById

// useHeroDetailData
const getHeroDetailData = (id) => {
  return axios.get(`http://localhost:4000/superheroes/${id}`);
};

const useHeroDetailData = (id) => {
  return useQuery(['heroes', id], () => {
    return getHeroDetailData(id);
  });
};

// detail example
const DetailExample = (props) => {
  const params = useParams();
  const { id: heroId } = params;
  const { data } = useHeroDetailData(heroId);

  return <div>{data?.data.name}</div>;
};

  1. queryKey에 id를 전달해서 그 쿼리를 식별할 수도 있다. 즉, 각 id에 해당하는 detail data를 queryKey로 식별한다.
  2. 아래와 같이 축약형으로 작성할 수도 있다.

// useHeroDetailData
const getHeroDetailData = ({ queryKey }) => {
  const id = queryKey[1];
  return axios.get(`http://localhost:4000/superheroes/${id}`);
};

const useHeroDetailData = (id) => {
  return useQuery(['heroes', id], getHeroDetailData); 
};

useQueries

useQueries<Result[]>(parameter: { queryKey, queryFn: () => Promise }[])

const getHeroById = (id) => {
  return axios.get(`baseURL/${id}`)
}

const parallelQueries = () => {
  const heroesId = [1, 2, 3];
  const results = useQueries(
    heroesId.map(id => ({
      queryKey: id, 
      queryFn: () => getHeroById(id) // return! 
    }))
  )
}

  1. useQueries를 이용하면 여러 개의 요청을 보낼 수 있다.
  2. 이 때, queryKey와 Fn 속성을 가진 객체로 이루어진 배열을 인자로 전달해 주어야 한다.
  3. useQueries 함수는 요청의 결과로 이루어진 배열을 반환한다.

dependent queries

어떤 쿼리의 결과에 따라 다음 쿼리를 다르게 요청해야 하는 때도 있다. 즉, 이어지는 후속 요청이 이전 쿼리의 결과에 의존한다.

const {data: user} = useQuery(/*...*/);
const email = user?.data.email;

useQuery(
  'example', exampleFn, {
    enabled: !!email, // 쿼리로 요청한 데이터에 email이 있을 때에만, 해당 쿼리를 요청한다
    }
)

  1. enabled가 false로 설정되어있을 때에는 컴포넌트가 마운트 되더라도, 쿼리를 요청하지 않는다.
  2. 대신 플래그로 전달한 email 데이터가 존재하면 => 쿼리를 요청한다.

useQueryClient | initial query data

메인 페이지에서 전체 아이템의 리스트를 fetching 했기 때문에, details 페이지에서 특정 아이템 하나를 다시 fetching 하는 것은 불필요할 수 있다. useQueryClient 훅을 이용하면, 전체 아이템 리스트를 initial 데이터로 설정한 뒤, details 페이지에서는 background-refetching만 일어나도록 할 수 있다(실제로 데이터가 변경되었을 때에만 화면을 재렌더링 할 수 있다).

import { useQuery, useQueryClient } from 'react-query'

const useHeroDetailData = (id) => {
  const queryClient = useQueryClient()

  return useQuery(['heroes', id], getHeroDetailData, {
    initialData: () => {
      const hero = queryClient
        .getQueryData('heroes') // queryKey
        ?.data?.find((heroItem) => heroItem.id === parseInt(id))

      if (hero) return { data: hero }
      else return undefined
    },
  })
}


+) useQueryClient() vs. new QueryClient() | ??? hook이 메서드 두개를 더 반환한다.

paginated queries

// useData hook 
export const getSuperHeroes = (pageNumber) => {
  return axios.get(`http://localhost:4000/superheroes?_page=${pageNumber}`)
}
const useHeroData = (pageNumber) => {
  return useQuery(['heroes', pageNumber], () => {
    getSuperHeroes(pageNumber);
  }, {
    keepPreviousData : true; 
  })
}
export default useHeroData

// example component
const Example = () => {
  const [pageNumber, setPageNumber] = useState(1)
  const { data } = useHeroData(pageNumber)
  // ... 
}

  1. keepPreviousData : false vs. true
    • false(default) | 이전과 다른 queryKey를 가진 쿼리를 요청하면, 그 쿼리에 대한 요청은 최초이므로(그 쿼리에 대한 데이터가 없으므로) isLoading 상태가 true가 된다. 따라서 (처음 접속할 때) pageNumber가 바뀔 때 마다 isLoading 상태가 true가 된다. 
    • true | data에 저장되는 useQuery의 queryKey(['heroes', pageNumber])가 변경되더라도, 새로운 데이터를 요청할 때, 이전에 fetching한 데이터를 유지할 수 있다(isLoading 상태를 true로 업데이트 하지 않는다). 
    • +) isLoading | when loading for the first time and has no data yet.
  2. queryKey 배열에 pageNumber를 넘겨주지 않으면 화면 업데이트가 일어나지 않는다. (내가 이해한 바로는) 각 페이지에 대한 요청을 유니크한 쿼리로 식별할 수 없기 때문이다. 

useInfiniteQuery | 이해안됨 패스

useMutation | sending data

// useHeroData
import { useMutation } from 'react-query'

export const postSuperHero = (heroData = {}) => {
  return axios.post(`${BASE_URL}/superheroes`, heroData)
}

export const usePostHeroData = () => {
  return useMutation(postSuperHero)
}

// example 
const { mutate: addHero } = usePostHeroData()
const addHero = () => {
  addHero({name: inputRef.current.value});
}

  1. create, update, delete와 관련된 요청을 할 때에는, useQuery가 아니라 useMutaion을 사용한다.
  2. useMutaion은 인자로 queryKey를 받지 않고(받을 필요가 없다), queryFn 만을 받는다.
  3. mutate 함수 뿐만 아니라, isSuccess 등과 같은 유용한 상태를 반환해 준다.

ui & database sync

1. query invalidation

ui와 데이터베이스의 상태를 같게 유지하려면, useMutaion을 이용해서 데이터베이스의 데이터를 수정한 후에, 수동적으로 데이터를 re-fetch 해 주어야 한다. 클라이언트 측에서는 데이터베이스의 변화를 감지할 수 없기 때문이다. 데이터를 수정하는 요청을 보내면, 다시 전체 데이터를 불러오는 방식을 이용하면 데이터 베이스와 ui의 sync를 맞출 수 있다. 리액트 쿼리에서는 invalidateQueries를 이용해서 이 작업을 수행한다.

import { useMutation, useQueryClient } from 'react-query'

export const usePostHeroData = () => {
  const queryClient = useQueryClient()

  return useMutation(postSuperHero, {
    onSuccess: () => {
      queryClient.invalidateQueries('heroes')
    },
  })
}

  1. useMutaion의 옵션으로 요청이 성공했을 때 실행하는 onSuccess 콜백 함수를 전달한다.
  2. 이 함수에서 query를 무효화(invalidate)하는 함수를 실행하는데, 어떤 쿼리를 무효화 할 것인지를 queryKey를 통해 지정할 수 있다.
  3. 쿼리가 무효화되면, 리액트 쿼리는 그 쿼리에 해당하는 요청을 refetch 한다.
  4. 정리) post 요청이 성공하면 이전의 쿼리를 무효화하고, 리액트 쿼리는 무효화된 쿼리를 refetch 하기 때문에 변경된 최신 상태의 데이터를 받아올 수 있다.

2. setQueryData

post => get 요청을 사용하는 대신, post 요청의 response 만을 이용해도 ui에서 최신 상태의 데이터를 보여줄 수 있다. 네트워크 요청(get)을 하지 않아도 되기 때문에 더 좋은 UX를 제공할 수 있다는 장점이 있다.
+) post의 response에는 데이터 베이스에 추가(수정)하고자 하는 데이터 객체가 들어있다.

import { useMutation, useQueryClient } from 'react-query'

export const usePostHeroData = () => {
  const queryClient = useQueryClient()

  return useMutation(postSuperHero, {
    onSuccess: (data) => {
      console.log(data.data); // 사용자가 post 하고자 하는 데이터 객체가 들어있다
      queryClient.setQueryData('heroes', (prev) => ({
        ...prev,
        data: [...prev.data, data.data],
      }))
    },
  })
}

  1. setQueryData를 이용하면, query cache를 업데이트 할 수 있다.
  2. queryData에는 다양한 정보가 들어있기 때문에, 그 정보들은 그대로 유지하고(prev), data 항목만 업데이트 한다.
  3. 네트워크 요청(get)을 보내지 않더라도 데이터베이스와 ui의 sync를 유지할 수 있다.

3. optimistic updates | 이해 안됨 패스

ReactQuery DevTools

import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ReactQueryDevtools } from 'react-query/devtools';

const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      <Example />
      <ReactQueryDevtools initialIsOpen={false} position='bottom-right' />
    </QueryClientProvider>
  )
}

  1. 우측 하단에(position 속성) 리액트 쿼리 개발자 도구 버튼이 생긴다.
  2. 쿼리를 사용한 페이지에서, queryKey로 전달한 값을 패널에서 확인할 수 있다.
  3. 뿐만 아니라 쿼리의 상태를 나타내는 4개의 뱃지(fresh, fetching, stale, inactive)를 확인할 수 있다.

'Study > npm Libraries' 카테고리의 다른 글

React with web accessibility  (0) 2022.09.19
React-router(v6)  (0) 2022.07.03
React-router  (0) 2022.07.01
React-redux & Redux-toolkit  (0) 2022.06.30
what is Redux?  (0) 2022.06.29