🍇 리액트쿼리란?
데이터 패칭 라이브러리로, 데이터를 패칭하는 기능만 지원하는게 아니라 Devtools, 캐싱, 서버 상태 동기화 및 업데이트 등 많은 기능을 지원한다.
데이터를 스토어에 저장한 후 전역에서 해당 데이터를 사용할 수 있게 해주는 redux와 비슷한 것을 알 수 있으며 redux의 액션, 리듀서 등의 보일러플레이트를 작성하지 않고 서버 데이터를 관리 가능.
(Redux는 전역 상태 관리를, React Query는 서버에서 받아온 데이터 관리를 하면서 역할을 분담)
🍇 기본 설정
import { QueryClient, QueryClientProvider, Hydrate } from "react-query"; const [queryClient] = React.useState(() => new QueryClient()); //app.tsx <QueryClientProvider client={queryClient}> <Hydrate state={pageProps.dehydratedState}> <Component {...pageProps} /> </Hydrate> </QueryClientProvider>
staleTime
더 길게 지정하면 staleTime쿼리가 데이터를 자주 다시 가져오지 않습니다.
refetchOnWindowFocus
예상치 못한 리패치가 실행될 시,
refetchOnMount, refetchOnWindowFocus, refetchOnReconnect 와 refetchInterval로 제어
cacheTime
기본적으로 "비활성" 쿼리는 5분 후에 가비지 수집 됩니다.이를 변경하려면 cacheTime쿼리 의 기본값 을 1000 60 5밀리초가 아닌 다른 값 으로 변경할 수 있습니다 .
retry, retryDelay
실패한 쿼리는 자동으로 3번 재시도오류 나타내기전에 롤백에 관한 옵션
//사용 예시 const { isLoading, error, data } = useQuery("getUserProfile", getProfile, { refetchOnMount: "always", retry: true, onError: (error) => { ConsoleDIR(error); }, });
🍇 세가지 컨셉
🫐 Queries
useQuery
훅을 이용하여 해당 endpoint 해당하는 모든 데이터의 READ(GET) 와 같은 비동기 요청을 할 수 있다. (쿼리가 동적으로 바뀌어야 하는 경우 useQueries
사용)useQuery의 첫 번째 인자 에는 고유한 키가 들어가며, 쿼리 키를 기준으로 묶여있는 데이터를 비동기적으로 선언하여 데이터를 캐싱해 온다. 두 번째 인자에는 data를 resolve 하거나 error를 뱉는 Promise를 리턴하는 함수를 넣는다.
(만약 서버의 데이터가 변경되는 경우에는 Mutations 가 더 적절하다.)
👉🏼 query 기본 형태
데이터를 가져올때 isLoading / isError / isSuccess / isIdle 총 네가지의 상태가 있다.
각각 로딩 / 에러 / 데이터 fetch 성공 / 비활성화 상태를 뜻한다.
각각의 상태에서 나타내는 property
- error - isErrror 상태에서 error 프로퍼티를 사용하여 에러메세지 확인가능
- data - 데이터를 성공적으로 가져온 상태에서 data 프로퍼티를 이용하여 확인가능. 여기서 데이터가 isSuccess 인 상태를 가정하여 데이터를 가져온다.
- isFetching - 어떠한 상태에서도 쿼리를 가져오는 경우
일반적으로 로딩상태를 거쳐 에러 혹은 데이터를 렌더링 한다.
const Sample = () => { const { status , isLoading, isError, data, error } = useQuery('getUserData', getUserProfile) // if(status === 'loading') 과 같다. if (isLoading) { return <span>Loading...</span> } // if(status === 'error') if (isError) { return <span>Error: {error.message}</span> } return ( <ul> {data.map(todo => ( <li key={todo.id}>{todo.title}</li> ))} </ul> ) }
👉🏼 query key
쿼리 키로 올수 있는것은 단순 string 또는 객체, string 을 배열로 묶은 형태가 올 수 있다.
//쿼리 키를 string 으로 정의한 사용 예시 const posts = useQuery('getAllBlogPosts', getAllPost) //쿼리 키를 배열로 정의한 사용 예시 const [pagination, setPagination] = useState<{ page: number; pageSize: number; total: number; }>({ page: 1, pageSize: 10, total: 20, }); const { data: posts, isLoading } = useQuery( ["getAllBlogPosts", pagination?.page, pagination?.pageSize], () => getAllPost({ ...(pagination.page && { page: pagination.page }), ...(pagination.pageSize && { pageSize: pagination.pageSize }), }), );
쿼리 키를 배열로 가져올 시 ,
배열내부의 객체순서가 변경되어도 쿼리키는 동일한것으로 간주되지만
배열순서가 뒤바뀔 경우 쿼리키가 동일해도 다른것으로 간주한다.
//배열 내부의 객체순서는 변경되어도 상관없다. useQuery(['getAllBlogPosts', { status, page }], ...) useQuery(['getAllBlogPosts', { page, status }], ...) useQuery(['getAllBlogPosts', { page, status, other: undefined }], ...) //배열 순서를 지켜야 제대로 된 데이터를 받아올 수 있다. useQuery(['getAllBlogPosts', status, page], ...) useQuery(['getAllBlogPosts', page, status], ...) useQuery(['getAllBlogPosts', undefined, page, status], ...)
매개변수로 받은 정보를 리액트 쿼리로 보내주어야 할 경우
쿼리 키에 해당 매개변수를 넣어 query function 에 적용해준다.
const User({ userInfo }) { const result = useQuery(['getUserData', getUserProfile], () => fetchTodoById(todoId)) }
👉🏼 Dynamic Parallel Queries with
useQueries
쿼리 수가 여러개일 경우 렌더링 → 렌더링으로 변경되는것은 훅 규칙을 위반하게 된다. 이럴때 수동적으로 데이터를 불러오는
useQuery
대신 useQueries
를 사용하여 동적으로 쿼리를 실행할 수 있다.function App({ users }) { const userQueries = useQueries( users.map(user => { return { queryKey: ['user', user.id], queryFn: () => fetchUserById(user.id), } }) ) }
//특정한 값이 있을때만 불러오는 옵션 enabled const { data: post } = useQuery(["post", user?.postId], getPost, { enabled: !!user?.postId, });
🫐 Mutations
useMutation
훅을 이용하여 Create(POST) / Update (PUT)/ Delete(Delete) / server state에 사이드 이펙트를 일으키는 경우 사용된다.⇒ 이벤트 핸들러 함수, 혹은 조건부로 useQuery를 호출하면 최상위에서 호출해야한다는 훅의 규칙에 위배되기 때문에 데이터 값이 변경될 경우 useMutation 권장
- useMutation이 반환하는 객체 프로퍼티로 제공되는 상태값은 useQuery와 동일하다.
- mutation.reset : 현재의 error와 data를 모두 지울 수 있음
// This will work const CreateTodo = () => { const mutation = useMutation(formData => { return fetch('/api', formData) }) const onSubmit = event => { event.preventDefault() mutation.mutate(new FormData(event.target)) } return <form onSubmit={onSubmit}>...</form> } // 리액트 16버전 이하에서는 사용불가. const CreateTodo = () => { const mutation = useMutation(event => { event.preventDefault() return fetch('/api', new FormData(event.target)) }) return <form onSubmit={mutation.mutate}>...</form> }
- 두번째 인자로 콜백 객체를 넘겨줘서 라이프사이클 인터셉트 로직을 짤 수도 있다.
useMutation(addTodo, { onMutate: variables => { // 뮤테이션 시작 // onMutate가 리턴하는 객체는 이하 생명주기에서 context 파라미터로 참조가 가능하다. return { id: 1 } }, onError: (error, variables, context) => { // 에러가 났음 console.log(`rolling back optimistic update with id ${context.id}`) }, onSuccess: (data, variables, context) => { // 성공 }, onSettled: (data, error, variables, context) => { // 성공이든 에러든 어쨌든 끝났을 때 }, })
/* 북마크 추가 api export const updateBookmark = async (roomID: string): Promise<void> => { return await axios.put(`/user/profile/bookmark/${roomID}`); } 북마크 삭제 api export const deleteBookmark = async (roomID: string): Promise<void> => { return await axios.delete(`/user/profile/bookmark/${roomID}`); }; */ const queryClient = useQueryClient(); //북마크 추가 const addBookMarkMutation = useMutation( (roomID: string) => updateBookmark(roomID), { //뮤테이션이 성공한다면, //쿼리의 데이터를 invalidate해 관련된 쿼리가 리패치되도록 만든다. onSuccess: () => queryClient.invalidateQueries("getUserBookMarkStays"), onError: (error: any) => { ToastError("Failed to update bookmark"); }, }, ); //북마크 삭제 const deleteBookMarkMutation = useMutation( (roomID: string) => deleteBookmark(roomID), { //뮤테이션이 성공한다면, //쿼리의 데이터를 invalidate해 관련된 쿼리가 리패치되도록 만든다. onSuccess: () => queryClient.invalidateQueries("getUserBookMarkStays"), onError: (error: any) => { ToastError("Failed to update bookmark"); }, }, ); const handleClick = async (roomID: string) => { if (!isAuthenticated) { openLoginModal(); return; } if (bookmarks.includes(roomID)) { deleteBookMarkMutation.mutate(roomID); } else { addBookMarkMutation.mutate(roomID); } }; <button type="button" className={`absolute right-[18px] top-[180px]`} onClick={() => handleClick(roomInfo?.uuid)} > <HeartIcon className={`w-[25px] h-[23px] text-text-tint ${isBookMarked && "text-item-favorite" }`} /> </button>
🫐 Query invalidation
query가 오래 되었다는 것을 판단하고 다시 fetch해올 때, queryClient.invalidateQueries 함수를 사용한다.
stale
최신화가 필요한 데이터라는 의미로, stale한 상태가 되면 다음의 경우에 refetch 된다.
- 새로운 query 인스턴스가 마운트될 때 ( 브라우저 화면을 이탈했다가 다시 포커스할 때 )
- 네트워크가 다시 연결될 때
- 특별히 설정한 refetch interval에 의해서 (refetchInterval) 조작될때
한마디로, 데이터를 리프레쉬 해야할 때 사용.
포스트 요청을 하거나 삭제 요청을 했을 때 화면에 보여주는 데이터에 변화를 줘야 한다.
그러나 query 키가 변하지 않으므로 강제 리프레쉬를 해야할 필요가 있다.
이런 때에는 queryClient의 invalidateQueries 메소드를 이용해서 query 키를 조작한다.
// useMutation 을 사용하지 않고 delete 메소드 사용된 경우 // useQuery 로 불러오기 const { data, isLoading, error } = useQuery( [ "getActivationCode", pagination?.current, pagination?.pageSize, searchCouponInput, ], () => getActivationCode({ pageNum: pagination?.current, limit: pagination?.pageSize, code: searchCouponInput, }), { onSuccess: (data) => { setPagination((state) => ({ ...state, total: data.count, })); setSearchClicked(false); }, }, ); const handleDelete = useCallback(async () => { try { await deleteActivationCode(selectCouponData[0].uuid); queryClient.invalidateQueries("getActivationCode"); notification.success({ message: "쿠폰이 삭제되었습니다", }); setSelectCouponData([]); setSelectionType(); } catch (error) { ConsoleDIR(error); notification.error({ message: "쿠폰 삭제에 실패했습니다", }); } }, [queryClient, selectCouponData, setSelectCouponData, setSelectionType]);
👇🏼 전체 사용 예시
import { useQuery, useMutation, useQueryClient, QueryClient, QueryClientProvider, } from 'react-query' import { getTodos, postTodo } from '../my-api' // Create a client const queryClient = new QueryClient() function App() { return ( // Provide the client to your App <QueryClientProvider client={queryClient}> <Todos /> </QueryClientProvider> ) } function Todos() { // Access the client const queryClient = useQueryClient() // Queries const query = useQuery('todos', getTodos) // Mutations const mutation = useMutation(postTodo, { onSuccess: () => { // Invalidate and refetch queryClient.invalidateQueries('todos') }, }) return ( <div> <ul> {query.data.map(todo => ( <li key={todo.id}>{todo.title}</li> ))} </ul> <button onClick={() => { mutation.mutate({ id: Date.now(), title: 'Do Laundry', }) }} > Add Todo </button> </div> ) } render(<App />, document.getElementById('root'))
🍇 Next.js ( SSR ) 에서의 react-query 사용
🫐 SSR
리액트 쿼리는 서버에서 데이터를 프리패칭하여 queryClient 에 전달하는 두가지 방법이 있다.
- 데이터를 미리 가져와서 initialData 로 전달하기
- 서버에서 쿼리를 가져와서
🫐 initialData
useQuery 메소드의 initialData 옵션을 통해
getStaticProps getServerSideProps
에서 사용가능.interface Props { postData: IPostData; } const Blog: FC<Props> = ({ postData }) => { const [pagination, setPagination] = useState<{ page: number; pageSize: number; total: number; }>({ page: 1, pageSize: 12, total: 12, }); const [mainPost, setMainPost] = useState<any>(""); const { data: posts, isLoading } = useQuery( ["getAllBlogPosts", pagination?.page], () => getAllPost({ ...(pagination.page && { page: pagination.page }), ...(pagination.pageSize && { pageSize: pagination.pageSize }), }), { onSuccess: (data) => { const mainPostData = data && data?.data[0]; pagination.page === 1 && setMainPost(mainPostData); }, initialData: postData, }, ); const handleContentPagination = (index: number) => { setPagination((prevState) => ({ ...prevState, page: index, })); }; export const getServerSideProps: GetServerSideProps = async () => { try { const postData = await getAllPost({ page: 1, pageSize: 12, }); return { props: { postData, }, }; } catch (error) { return { props: {}, }; } };
🫐 Hydration → 더 공부하기
(updated link :
React Query는 Next.js의 서버에서 여러 쿼리를 미리 가져온 다음 해당 쿼리를 queryClient 로 dehydrating 하는 것을 지원한다.
즉, 서버는 페이지 로드 시 즉시 사용할 수 있는 마크업을 미리 렌더링할 수 있으며 JS를 사용할 수 있게 되면 React Query는 라이브러리의 전체 기능으로 이러한 쿼리를 업그레이드하거나 hydrate 할 수 있다 .
여기에는 서버에서 렌더링된 이후로 오래된 쿼리가 클라이언트에서 다시 가져오는 것이 포함된다.
서버에서 쿼리 캐싱을 지원하고 hydration를 설정하려면:
- 앱 내부와 인스턴스 ref(또는 React 상태)에서 새 QueryClient 인스턴스 생성
→ 구성 요소 수명 주기당 한 번만 QueryClient를 생성하면서 데이터가 서로 다른 사용자와 요청 간에 공유되지 않는다.
- 앱 구성 요소를 래핑 하고 클라이언트 인스턴스로 전달
- 앱 구성 요소를 래핑 하고 다음에서 dehydratedState 를 props로 전달한다.
const HouseListPage: FC<{ dehydratedStateData; }> = ({ dehydratedStateData }): JSX.Element => { const { data: allAvailableRoomData } = useQuery( ["getAvailableRooms", options?.livingType, options?.housingType], async () => { return await getAvailableRooms(options); }, { initialData: dehydratedStateData?.queries[0]?.state.data, onError: function handleError(err) { return <div>{err}</div>; }, }, ); ...~ export const getServerSideProps: GetServerSideProps = async (context) => { const { query } = context; const options = parseQuery(query); const queryClient = new QueryClient(); await queryClient.prefetchQuery("getAvailableRooms", () => getAvailableRooms(options), ); try { return { props: { dehydratedStateData: dehydrate(queryClient), }, }; } catch (err) { throw err; } };
🍇 장,단점
😁 장점
- 비동기 관련한 타이핑이 정말 많이 줄어든다
- Redux같은 전역 상태 저장소의 store에 동기적으로 업데이트되는 데이터와 액션만 남길 수 있어 크기를 줄이고, Saga는 아예 대체해버린다.
- 캐싱과 리패칭을 개발자가 구현하지 않아도 알아서 지원한다.
- 풍부한 옵션을 제공해 굉장히 많은 부분에서 custom이 가능하다.
- DEV-TOOLS 를 제공해 쿼리의 호출 상태를 바로 브라우저에서 확인할 수 있어 디버깅에 용이하다.
😞 단점
- 컨벤션을 정하지 않을 시 , 비동기 요청이 개별 컴포넌트에 더 강하게 의존해버릴 수 있을 것 같다. 컴포넌트에 Query관련 로직을 직접 사용한다면 앱이 커질 경우 개별 컴포넌트에 숨겨져 있을 수 있는 비동기 요청을 잘 못찾는 경우도 생길 수 있을 것 같다.
- 커스텀 훅으로 여러 query 호출을 분류해 묶어서 관리한다던지 등의 방법으로 비즈니스 로직을 UI로직과 분리하기 위해 React Query를 훅으로 관리하면 해결 가능할듯