❓React Query
- 서버 상태를 불러오고, 캐싱하며, 지속적으로 동기화하고 업데이트하는 작업을 도와주는 라이브러리
- 컴포넌트 내부에서 간단하고 직관적으로 API 사용 가능
- 데이터 캐싱(데이터의 복사본 저장) -> 동일한 데이터에 대한 반복적인 비동기 데이터 호출 방지 -> 서버에 대한 부하를 줄임
- 최신데이터(fresh) 기존의 데이터(stale)
- Client 데이터는 상태 관리 라이브러리가 관리, Server 데이터는 리액트 쿼리가 관리
❇️ App.tsx or index.tsx 파일에서 QueryClientProvider를 사용해서 모든 컴포넌트를 감싸기
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import { QueryClient, QueryClientProvider } from 'react-query'
import { BrowserRouter } from 'react-router-dom'
const queryClient = new QueryClient()
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<App />
</BrowserRouter>
</QueryClientProvider>
</React.StrictMode>,
)
❇️ useQuery
import { useQuery } from 'react-query'
import axios from 'axios'
export const ReactQuery = () => {
const { isLoading, data, isError, error } = useQuery('get-product', () => {
return axios.get(
'https://mypocketbase.fly.dev/api/collections/products/records',
)
})
if (isLoading) return <>Loading...</>
if (isError) return <>{error.message}</>
return (
<>
<div className='text-4xl'>ReactQuery</div>
<ul className='list-disc p-4'>
{data &&
data.data?.items?.map(product => (
<li key={product.id}>
{product.name} / {product.price}
</li>
))}
</ul>
</>
)
}
useQuery 훅 첫 번째 인자(문자열) 'get-product' 부분은 queryKey임!
이 queryKey로 구분해서 data fetching 수행! -> 이 이름으로 캐시 저장
그래서 다시 라우팅하면 캐시 내용 돌려보내줌 -> 로딩x
그러면 실제 api값이 변경된다면? -> 백그라운드에서 조용히 api 호출을 해서 캐시에 있는 데이터를 최신화시킴(isFetching)
두 번째 인자엔 콜백 함수 -> data fetching을 위한 axios 코드를 넣음
다음과 같이 함수를 따로 만들 수도 있음 -> 간결해
const fetchProducts = () => {
return axios.get(
"https://mypocketbase.fly.dev/api/collections/products/records"
);
};
export const ReactQuery = () => {
const { isLoading, data, isError, error } = useQuery("get-product", fetchProducts);
if (isLoading) return <>Loading...</>;
if (isError) return <>{error.message}</>;
...
...
}
isLoading, data, isError, error -> 기본적으로 제공해주는 변수
❇️ Devtools 컴포넌트 추가
import { Routes, Route } from 'react-router-dom'
import { Layout } from './components/Layout'
import { Home } from './components/Home'
import { AxiosQuery } from './AxiosQuery'
import { ReactQuery } from './ReactQuery'
import { ReactQueryDevtools } from 'react-query/devtools'
function App() {
return (
<>
<Routes>
<Route path='/' element={<Layout />}>
<Route index element={<Home />} />
<Route path='/axios-query' element={<AxiosQuery />} />
<Route path='/react-query' element={<ReactQuery />} />
</Route>
</Routes>
<ReactQueryDevtools initialIsOpen={false} position='bottom-right' />
</>
)
}
export default App
❇️cache
캐시의 기본 유지 시간은? 5분! -> 가장 좋은 시간...굳이 수정할 필요 x
캐시의 기본 시간 수정가능! ex) 5000->5초
const { isLoading, isFetching, data, isError, error } = useQuery(
'get-product',
fetchProducts,
{
cacheTime: 5000,
},
)
❇️stale
fresh <-> stale
isFetching주기는 staleTime으로 지정 가능!
const { isLoading, isFetching, data, isError, error } = useQuery(
'get-product',
fetchProducts,
{ staleTime: 60000 },
)
이렇게 하면 60초마다 데이터 갱신됨
리액트 쿼리의 캐시 상태 (inactive -> fetching -> fresh -> stale)
staleTime의 기본 값은? 0 -> 잘 조절해서 무분별한 api 호출이 일어나지 않도록 하자!
❇️refetch
컴포넌트가 라우팅 될 때마다 fetching이 일어나는 이유 -> refetchOnMount 항목이 true로 기본값 세팅 되어있기 때문!
const { isLoading, isFetching, data, isError, error } = useQuery(
'get-product',
fetchProducts,
{ staleTime: 3000, refetchOnMount: false },
)
false로 하면 Mount 됐을 때 fetching 작업이 일어나지 않음
추가로 refetchOnWindowFocus 도 있는데 브라우저 활성화 유무에 따라서 refetch 방식 결정
❇️polling
polling: 주기적으로 데이터를 가져오는 프로세스
refetchInterval 값은 기본값이 false!
-> refetch가 유저의 행위(refetchOnMount & refetchOnWindowFocus)에 의해 의존한다는 뜻!
refetchIntervals는 유저의 행위와는 별도로 polling작업을 할 수 있음!
const { isLoading, isFetching, data, isError, error } = useQuery(
'get-product',
fetchProducts,
{
refetchInterval: 2000,
refetchIntervalInBackground: true,
},
)
-> refetchInterval로 2초마다 fetching이 일어나도록 함
+ refetchIntervalInBackground를 기본값인 false에서 true로 변경함으로써 앱이 비활성화되었어도 fetching 작업 계속 일어남
주기적으로 데이터가 변경되는 앱에서 매우 유용하다!
❇️enabled
리액트 컴포넌트 마운트시 자동으로 useQuery 시작됨 -> enabled 기본 세팅이 true이기 때문!
그럼 유저의 요청에 의해 데이터를 가져와야 할 때는?
const { isLoading, isFetching, data, isError, error, refetch } = useQuery(
"get-product",
fetchProducts,
{
enabled: false,
}
);
if (isLoading || isFetching) return <>Loading...</>;
if (isError) return <>{error.message}</>;
return (
<>
<div className="text-4xl">ReactQuery</div>
<button
onClick={refetch}
className="py-2 px-4 border bg-slate-100 rounded-md"
>
fetch data
</button>
<ul className="p-4 list-disc">
</>
)
enabled를 false로 해놓으면 원하는 상황에 useQuery가 시작되게 할 수 있음!
기본 제공해주는 refetch 콜백함수를 이용하면 됨!
❇️커스텀 콜백 함수
useQuery 훅에서 성공 또는 실패했을 경우 커스텀 콜백 지정 가능!
const fetchProducts = () => {
return axios.get(
"https://mypocketbase.fly.dev/api/collections/products/records"
);
};
export const ReactQuery = () => {
const onSuccess = (data) => {
console.log("데이터 가져오기 후 사이드 이펙트 수행", data);
};
const onError = (error) => {
console.log("오류 발생 후 사이드 이펙트 수행", error);
};
const { isLoading, isFetching, data, isError, error } = useQuery(
"get-product",
fetchProducts,
{
onSuccess, //onSuccess: onSuceess
onError,
}
);
console.log({ isLoading, isFetching });
...
...
...
}
onSuccess 콜백 -> 데이터 활용
onError 콜백 -> 오류 처리
❇️데이터 변환
백엔드 - 프론트 간 데이터 형식의 차이가 있는 경우... -> select 옵션 이용!
const { isLoading, isFetching, data, isError, error } = useQuery(
'get-product',
fetchProducts,
{
onSuccess: onSuccess,
onError: onError,
select: data => {
const productName = data.data?.items.map(p => p.name)
return productName
},
},
)
+
<>
<div className='text-4xl'>ReactQuery</div>
<ul className='list-disc p-4'>
{/* {data &&
data.data?.items?.map((product) => (
<li key={product.id}>
{product.name} / {product.price}
</li>
))} */}
{data && data.map(productName => <li key={productName}>{productName}</li>)}
</ul>
</>
filter 메소드도 사용 가능
select: data => {
const productName = data.data?.items
.filter(p => parseInt(p.price) <= 9)
.map(p => p.name)
return productName
}
❇️커스텀 훅으로 만들어 재사용
예) src > hooks > useProductName.ts
import { useQuery } from "react-query";
import axios from "axios";
const fetchProducts = () => {
return axios.get(
"https://mypocketbase.fly.dev/api/collections/products/records"
);
};
export const useProductName = (onSuccess, onError) => {
return useQuery("get-product", fetchProducts, {
onSuccess: onSuccess,
onError: onError,
select: (data) => {
const productName = data.data?.items
.filter((p) => parseInt(p.price) <= 9)
.map((p) => p.name);
return productName;
},
});
};
사용
import { useProductName } from "./hooks/useProductName";
export const ReactQuery = () => {
const onSuccess = (data) => {
console.log("데이터 가져오기 후 사이드 이펙트 수행", data);
};
const onError = (error) => {
console.log("오류 발생 후 사이드 이펙트 수행", error);
};
const { isLoading, isFetching, data, isError, error } = useProductName(
onSuccess,
onError
);
...
...
...
}
❇️id로 특정 항목만 가져오기
보통 상세페이지 불러올 때 사용할 쿼리임!
import { useQuery } from 'react-query'
import axios from 'axios'
const fetchProductDetails = productId => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/${productId}`,
)
}
export const useProductId = productId => {
return useQuery(['product-id', productId], () =>
fetchProductDetails(productId),
)
}
배열의 첫 번째 인자엔 query 이름, 두 번째 인자엔 다이나믹으로 변하는 변수를 넣어줌( ['product-id', productId] )
❇️병렬 쿼리
하나의 컴포넌트에서 여러 api를 호출해야 할 때?
-> useQuery를 두 번 호출하는 것만으로도 간단히 처리 가능!
import { useQuery } from "react-query";
import axios from "axios";
const fetchProducts = () => {
return axios.get(
"https://mypocketbase.fly.dev/api/collections/products/records"
);
};
const fetchUsers = () => {
return axios.get(
"https://mypocketbase.fly.dev/api/collections/users/records"
);
};
export const ParallelQuery = () => {
useQuery("parallel-get-product", fetchProducts);
useQuery("parallel-get-users", fetchUsers);
return <div>ParallelQuery</div>;
};
그러면 쿼리가 두 개인데 데이터를 어떻게 구분? -> 별칭 사용
const {
data: productsData,
isLoading: productsLoading,
isError: productsError,
} = useQuery("parallel-get-product", fetchProducts);
❇️동적 병렬 쿼리
여러 개의 값이 있는 배열이 전달될 경우엔 어떻게 useQuery를 수행? -> useQuerise 함수 사용
import { useQueries } from 'react-query'
import axios from 'axios'
const fetchProducts = productId => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/${productId}`,
)
}
export const DynamicParallelQueries = ({ productIds }) => {
console.log(productIds)
const results = useQueries(
productIds.map(id => {
return {
queryKey: ['get-product', id],
queryFn: () => fetchProducts(id),
}
}),
)
console.log({ results })
return <div>DynamicParallelQueries</div>
}
❇️initial query data
전체 -> 상세로 갈 때 각각 쿼리 작업 하지 말고 캐시로부터 가져와서 쓰면 네트워크 사용량 절약 가능!
-> initialData 옵션과 queryClient를 사용하면 구현할 수 있음
import { useQuery, useQueryClient } from "react-query";
import axios from "axios";
const fetchProductDetails = (productId) => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/${productId}`
);
};
export const useProductId = (productId) => {
const queryClient = useQueryClient();
return useQuery(
["product-id", productId],
() => fetchProductDetails(productId),
{
initialData: () => {
const product = queryClient
.getQueryData("get-product")
?.data?.items.find((p) => p.id === productId);
if (product) {
return {
data: product,
};
} else return undefined;
},
}
);
};
주의점!
1. 찾은 데이터를 data: product 같은 형식으로 사용해야 함(ReactQueryDetails 컴포넌트에서 data.data로 사용해서)
2. 바로 원하는 값을 찾지 못했을 경우 undefined를 리턴해야 함(그래야 해당 쿼리 수행x)
❇️ useQuery의 강력한 기능?!
캐시 된 데이터를 화면에 먼저 보여주고 -> fetching을 해서 기존 데이터와 같으면 UI를 바꾸지 않고, 다르면 그제서야 UI를 바꿈
데이터 로딩 중엔 화면의 Layout이 깨짐 -> 스켈레톤 UI 사용하는 이유...
const { data, isLoading, isFetching } = useQuery(
['get-paginated', pageNumber],
() => fetchProducts(pageNumber),
{
keepPreviousData: true,
},
)
다음과 같이 keepPreviousData: true 옵션을 주면 됨!
❇️ useInfiniteQuery
무한 스크롤 구현하기 위한 함수!
페이지네이션 구현 코드로 살펴보자
import { Fragment } from 'react'
import { useInfiniteQuery } from 'react-query'
import axios from 'axios'
const fetchProducts = (page) => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/?perPage=4&page=${page}`,
)
}
export const PaginatedQuery = () => {
const {ƒ
data,
isLoading,
isFetching,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery(
['get-paginated'],
({ pageParam = 1 }) => fetchProducts(pageParam),
{
getNextPageParam: (_lastPage, pages) => {
if (pages.length < 3) {
return pages.length + 1
} else return undefined
},
},
)
if (isLoading) return <div>Loading...</div>
return (
<>
<div className='text-4xl'>ReactQuery</div>
{data &&
data.pages?.map((group, i) => (
<Fragment key={i}>
{group &&
group?.data.items.map(p => <div key={p.id}>{p.name}</div>)}
</Fragment>
))}
<div className='space-x-4'>
<button
className='border'
onClick={fetchNextPage}
disabled={!hasNextPage}
>
Load More
</button>
</div>
<div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
</>
)
}
fetchProducts 함수에서 page라는 함수 인자를 받고, pageParam = 1 로 디폴트 값을 1로 지정해서 넘겨줌
주의!
useInfiniteQuery는 queryKey를 꼭 배열로 제공해 줘야됨.
마지막 옵션이 getNextPageParam 콜백함수 -> 다음 페이지의 pageParam 값 제공
꼭 undefined를 리턴하는 코드도 작성해야함!
useInfiniteQuery는 페이지 정보를 가져다주고, 실제 그 pages에는 해당 페이지가 있고, group이란 항목이 있음 -> map 메서드를 두 번 써야됨
다음 페이지는 fetchNextPage 콜백 함수 사용
❇️ 무한 스크롤 구현(scrollHeight, scrollTop, clientHeight)
useEffect(() => {
let fetching = false;
const handleScroll = async (e) => {
const { scrollHeight, scrollTop, clientHeight } =
e.target.scrollingElement;
if (!fetching && scrollHeight - scrollTop <= clientHeight * 1.2) {
fetching = true;
if (hasNextPage) await fetchNextPage();
fetching = false;
}
};
document.addEventListener("scroll", handleScroll);
return () => {
document.removeEventListener("scroll", handleScroll);
};
}, [fetchNextPage, hasNextPage]);
❇️ 무한 스크롤 구현2(Intersection Observer)
https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API
Intersection Observer API - Web APIs | MDN
The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.
developer.mozilla.org
import { Fragment, useEffect, useRef, useCallback } from "react";
import { useInfiniteQuery } from "react-query";
import axios from "axios";
const fetchProducts = (page) => {
return axios.get(
`https://mypocketbase.fly.dev/api/collections/products/records/?perPage=4&page=${page}`
);
};
export const PaginatedQuery2 = () => {
const observerElem = useRef(null);
const { data, isLoading, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery(
["get-paginated"],
({ pageParam = 1 }) => fetchProducts(pageParam),
{
getNextPageParam: (_lastPage, pages) => {
if (pages.length < 3) {
return pages.length + 1;
} else return undefined;
},
}
);
const handleObserver = useCallback(
(entries) => {
const [target] = entries;
if (target.isIntersecting && hasNextPage) {
fetchNextPage();
}
},
[fetchNextPage, hasNextPage]
);
useEffect(() => {
const element = observerElem.current;
let options = {
root: null,
rootMargin: "0px",
threshold: 1,
};
const observer = new IntersectionObserver(handleObserver, options);
if (element) observer.observe(element);
return () => {
if (element) observer.unobserve(element);
};
}, [fetchNextPage, hasNextPage, handleObserver]);
if (isLoading) return <div>Loading...</div>;
return (
<>
<div className="text-4xl">ReactQuery</div>
{data &&
data.pages?.map((group, i) => (
<Fragment key={i}>
{group &&
group?.data.items.map((p) => <div key={p.id}>{p.name}</div>)}
</Fragment>
))}
<div className="loader" ref={observerElem}>
{isFetchingNextPage && hasNextPage ? "Loading..." : "No search left"}
</div>
</>
);
};
useEffect가 useRef보다 먼저 실행되는 것을 생각하며 element가 있는지 if문으로 체크하자
❇️ useMutaion
데이터를 post 할 때 사용하는 훅이 useMutaion
import { useState } from "react";
...
...
...
export const ReactQuery = () => {
const [name, setName] = useState("");
const [price, setPrice] = useState(0);
...
...
...
const handleCreate = () => {
console.log({ name, price });
};
return (
<>
<div className="text-4xl">ReactQuery</div>
<div className="space-x-2">
<input
className="border"
type="text"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<input
className="border"
type="number"
value={price}
onChange={(e) => setPrice(Number(e.target.value))}
/>
<button className="border" onClick={handleCreate}>
Create
</button>
</div>
...
...
</>
);
}
커스텀 useMutaion 훅 생성하기
import { useQuery, useMutation } from "react-query";
...
...
...
const addProduct = (product) => {
return axios.post(
"http://127.0.0.1:8090/api/collections/products/records", product
);
};
...
...
...
export const useAddProduct = () => {
return useMutation(addProduct)
}
커스텀 훅 사용하기
import { useProductName, useAddProduct } from './hooks/useProductName'
...
...
const { mutate: addProduct } = useAddProduct()
const handleCreate = () => {
console.log({ name, price })
const data = { name, price }
addProduct(data)
}
useAddProduct 훅에서 useMutation 훅을 받는데 거기서 mutate라는 함수만 받음
-> mutate 함수의 이름을 addProduct라고 alias 형태로 새로 지정해줌
-> handleCreate 함수에서 addProduct 함수를 실행시키면 됨!
근데 fetching 로딩 시간 걸림... -> POST 액션 후 바로 쿼리 갱신시키고 싶으면? -> queryKey를 이용해서 invalidate 하면 됨!
import { useQuery, useMutation, useQueryClient } from 'react-query'
export const useAddProduct = () => {
const queryClient = useQueryClient()
return useMutation(addProduct, {
onSuccess: () => {
queryClient.invalidateQueries('get-product')
},
})
}
useQueryClient를 이용해서 메모리상의 queryClient 객체를 얻어서 바로 invalidateQueries 함수를 실행
-> invalidateQueries 함수는 queryKey의 쿼리를 즉시 무효화 시키고 다시 fetching 시킴
-> post 액션 후에 시간지체 없이 바로 화면에 업데이트 된 정보 보임!
하지만 위와 같은 방법 말고 메모리의 쿼리에 직접 데이터를 업데이트하여 useQuery가 fetching을 하지 않도록 만들 수 있음! (post 이후 get이 무조건 일어나는데 이 불필요한 get을 없애는 것)
export const useAddProduct = () => {
const queryClient = useQueryClient()
return useMutation(addProduct, {
onSuccess: data => {
// queryClient.invalidateQueries("get-product");
queryClient.setQueryData('get-product', oldProductData => {
return {
...oldProductData,
data: [...oldProductData.data.items, data.data],
}
})
},
})
}
❇️Optimistic Updates
실제 post 액션은 백그라운드로 실행되는 것 -> 가장 이상적인 post 액션! = Optimistic Updates
import { v4 as uuid } from 'uuid';
...
...
...
export const useAddProduct = () => {
const queryClient = useQueryClient();
return useMutation(addProduct, {
// onSuccess: (data) => {
// // queryClient.invalidateQueries("get-product");
// queryClient.setQueryData("get-product", (oldProductData) => {
// return {
// ...oldProductData,
// data: [...oldProductData, data.data],
// };
// });
// },
onMutate: async (newProduct) => {
await queryClient.cancelQueries("get-product");
const previousProductData = queryClient.getQueryData("get-product");
queryClient.setQueryData("get-product", (oldProductData) => {
return {
...oldProductData,
data: [...oldProductData.data.items, { id: uuid(), ...newProduct }],
};
});
return {
previousProductData,
};
},
});
};
주의: 본인이 사용하는 DB가 리턴하는 JSON 데이터의 형식을 꼭 확인하고 숙지해야 함
작동 원리 : 'get-product' 쿼리를 먼저 취소 -> 예전 'get-product' 데이터를 previousProductData에 불러옴 -> setQueryData를 이용해서 직접 데이터를 수작업으로 쿼리에 업데이트
+ uuid는 id가 랜덤 텍스트일 경우 작성하기 위한 패키지
previousProductData 를 리턴해 주는 이유: onError 항목에서 처리하기 위함!
-> 실제 POST 액션이 에러가 나서 취소가 됐을 때 원상복구하기 위함
previousProductData로 원상복구(setQueryData)
onError 인자들: 1. error 객체 2. 사용자가 입력한 데이터 3. query 컨텍스트
onError: (_error, _product, context) => {
queryClient.setQueryData('get-product', context.previousProductData)
}
onSettled이 실행되는 조건 -> post 액션이 에러 or 성공
get-product 쿼리를 무효화시켜서 쿼리를 다시 fetching 하도록 하면 됨
onSettled: () => {
queryClient.invalidateQueries('get-product')
},
완성 코드
export const useAddProduct = () => {
const queryClient = useQueryClient()
return useMutation(addProduct, {
onMutate: async (newProduct) => {
await queryClient.cancelQueries("get-product");
const previousProductData = queryClient.getQueryData("get-product");
queryClient.setQueryData("get-product", (oldProductData) => {
return {
...oldProductData,
data: [...oldProductData.data.items, { id: uuid(), ...newProduct }],
};
});
return {
previousProductData,
};
},
onError: (_error, _product, context) => {
queryClient.setQueryData('get-product', context.previousProductData)
},
onSettled: () => {
queryClient.invalidateQueries('get-product')
},
})
}
출처: https://mycodings.fly.dev/blog
드리프트의 myCodings
드리프트의 myCodings.fly.dev!
mycodings.fly.dev
'React' 카테고리의 다른 글
[React-Query]prefetchQuery (0) | 2025.02.25 |
---|---|
useEffect의 의존성 배열 (0) | 2024.03.10 |
useRef (0) | 2024.03.10 |
리액트 개념 공부 (0) | 2024.02.09 |
리액트 상태 관리 라이브러리 (0) | 2024.01.06 |