react-query는 swr과 함께 API 통신 과정을 간결하면서도 강력하게 사용할 수 있게 해주는 라이브러리입니다(실제로 둘 다 http 캐시 무효 전략인 stale-while-revalidate에서 착안했다고 합니다). 공식 문서의 말을 빌리자면 선언적이면서도 간단하며 zeroconf가 react-query의 장점인데요. 실제로 비동기 로직을 꼼꼼하게 구현하려면 세심한 관리와 노력이 필요합니다. 이와 같은 수고를 덜어줄 수 있는 react-query를 공부해보고 한번 사용해보며 알아보았습니다.
프론트엔드 개발을 하다보면 상태를 관리하는 일이 정말 많은데요. 저희가 관리하는 상태란 무엇일까요?
제가 생각하는 상태는 이렇습니다.
세련된 UI, 뛰어난 UX가 대두됨에 따라 서비스의 규모가 커지고 코드의 양도 많아졌습니다.
자연스레 프론트엔드에서 관리하는 상태도 많아졌고 이로 인해 발생하는 다양한 이슈들을 해결하기 위한 방안들도 강구되고 있는 상황입니다(Props Drilling을 막기 위한 Redux, Mobx 등).
특히 백엔드로부터 받아오는 데이터는 매번 요청부터 응답까지 성공과 실패를 고려한 로직을 짜느라 바빴고, 응답 이후 클라이언트의 상태와 동기화를 시켜주어야 했습니다. 그럼에도 불구하고, 백엔드에서 받아오는 데이터들은 언제든 조작이 가능한 데이터들이기 때문에 클라이언트의 의도와 달리 데이터가 변경될 수 있고 잠재적으로 out of date가 될 가능성을 지녔습니다.
이러한 관점에서 react-query는 클라이언트에서 관리할 데이터와 서버에서 관리할 데이터를 분류해서 바라보게 해줍니다.
실제로 클라이언트에서 Server State도 함께 관리하다보면 store가 매우 혼잡해집니다(얼핏 보면 상태 관리 코드가 아니라 비동기 통신 코드같기도 합니다). 제가 사용해본 방법 중 가장 복잡했던 방식은 saga였는데요.
function* deletePost({id}) {
try {
yield put({ type: DELETE_POST_SUCCESS, deletePostId: id });
} catch (err) {
yield put({ type: DELETE_POST_ERROR, payload: err });
}
}
function* watchAddComment() {
yield takeLatest(DELETE_POST_REQUEST, addComment);
}
강력한 기능들을 제공하는 미들웨어임은 틀림없지만 간단한 비동기 로직에도 코드 양이 상당하고 초기 세팅, 러닝 커브 등 다양한 불편한 점들이 있었습니다. react-query를 사용해보니 쉬운 난이도와 가독성 대비 제공하는 기능들도 강력해서 충분히 만족할 수 있었습니다.
$ npm i react-query
# or
$ yarn add react-query
import { QueryClient, QueryClientProvider, useQuery } from 'react-query'
const queryClient = new QueryClient()
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<Todos />
</QueryClientProvider>
)
}
react-query를 사용하다보면 Server State가 전역적으로 관리된다는 느낌을 받는데요. Provider에서 눈치채신 분들도 계시겠지만 실제로 react-query는 react가 제공하는 context를 기반으로 만들어졌고 데이터를 관리한다고 합니다.
import { useQuery } from 'react-query'
function App() {
const info = useQuery('todos', fetchTodoList, options)
}
위 옵션들을 보면 낯선 옵션들이 존재하는 것을 확인할 수 있습니다(stale은 뭐고 cacheTime은 뭐지? refetchOnMount는 true 아니면 false지 always는 또 뭐지?). 원활한 이해를 위해 react-query의 핵심 사항들을 짚어보고 가겠습니다.
Query의 라이프 사이클
query는 크게 5가지의 상태를 가집니다.
차례대로 fetching > fresh > stale > inactive > deleted
fresh < -- > stale : 신선하거나 오래된 상태의 컨셉으로 진행됩니다.
query는 이러한 흐름을 갖고 진행됩니다.
예를 들어 query 옵션을 { refetchOnMount : "always", staleTime: 5000 } 로 설정한다면 fetching 이후 5초간은 fresh한 상태가 됩니다. 5초뒤에는 stale 상태가 됩니다. 만약 5초 이내 query가 unmount > mount 되었다면 refetchOnMount 옵션이 "always" 인 관계로 fresh한 상태지만 refetch가 일어나게 됩니다.
useQuery에 사용되는 key는 query를 식별하는 unique한 값으로 string과 array 두 가지 타입만 사용이 가능합니다.
useQuery('todos', ...) // queryKey === ['todos']
다만, string도 내부적으로 단일 배열로 변환됩니다.
쿼리 데이터 식별에 추가적인 정보가 필요하다면 array 형태로 key를 제공할 수 있습니다.
useQuery(['todo', 5], ...)
useQuery(['todo', 5, { preview: true }], ...)
다만 배열의 순서에 따라 모두 개별 key로 인식합니다.
useQuery(['todos', status, page], ...)
useQuery(['todos', page, status], ...)
//not equal
쿼리를 여러개 선언해야 하는 상황에서도 별다른 스킬 없이 선언하면 됩니다. react-query가 내부적으로 병렬적으로 처리해준다고 합니다.
function App () {
const usersQuery = useQuery('users', fetchUsers)
const teamsQuery = useQuery('teams', fetchTeams)
const projectsQuery = useQuery('projects', fetchProjects)
...
}
예를 들어 A 라는 컴포넌트에서 todo query를 호출한 뒤 B 라는 컴포넌트에서 또 다시 todo query를 호출한다면 어떻게 될까요? API 호출이 한번 더 일어날까요? 정답은 "query가 stale한 경우만 API 호출이 발생한다" 입니다. 즉 fresh한 상태일 경우 API 호출 자체가 일어나지 않습니다. stale한 query는 refetch 된 후 A 컴포넌트와 B 컴포넌트에 최신화된 상태로 전달됩니다.
앞서 살펴본 query는 데이터를 fetching 하는 것이 주목적이었다면 mutation은 create/update/delete에 사용을 권장합니다.
function App() {
const mutation = useMutation(newTodo => {
return axios.post('/todos', newTodo)
})
return (
<div>
{mutation.isLoading ? (
'Adding todo...'
) : (
<>
{mutation.isError ? (
<div>An error occurred: {mutation.error.message}</div>
) : null}
{mutation.isSuccess ? <div>Todo added!</div> : null}
<button
onClick={() => {
mutation.mutate({ id: new Date(), title: 'Do Laundry' })
}}
>
Create Todo
</button>
</>
)}
</div>
)
}
사용법도 매우 직관적이며 useQuery와 마찬가지로 다양한 프로퍼티들을 반환하기 때문에 상황에 맞게 사용이 용이합니다.
mutation은 라이프 사이클 로직 옵션도 제공합니다. axios의 인터셉터와 유사한 컨셉인 것 같습니다.
useMutation(addTodo, {
onMutate: variables => {
// ... //
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) => {
// ... //
},
})
특히 onMutate같은 경우 로직이 시작되자 마자 실행이 됩니다. 따라서 좋아요와 같은 Optimistic update를 적용할 때 유용해 보입니다.
invaliation은 특정 query가 stale 상태 혹은 개발자가 원하는 특수한 상태일 때 query를 처분(무효화) 하기 위해 사용합니다. 대개는 mutation이 일어난 후 기존 데이터가 stale 상태일 확률이 높기 때문에 mutation의 onSuccess 옵션과 함께 사용합니다.
import { useMutation, useQueryClient } from 'react-query'
const queryClient = useQueryClient()
const mutation = useMutation(addTodo, {
onSuccess: () => {
queryClient.invalidateQueries('todos')
queryClient.invalidateQueries('reminders')
},
})
이 과정을 통해 특정 query는 stale 상태로 전환되고, 현재 rendering 되고 있는 query 들은 백그라운드에서 refetch 과정을 거친 후 최신 데이터를 유지하게 됩니다.
react-query는 데브툴을 제공합니다.
import { ReactQueryDevtools } from 'react-query/devtools'
function App() {
return (
<QueryClientProvider client={queryClient}>
{/* The rest of your application */}
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
)
}
key별 query들의 실시간 상태 정보를 제공하는 등 디버깅에 도움이 되는 기능들이 많습니다.
간단한 공식문서 예시입니다.
https://codesandbox.io/s/github/tannerlinsley/react-query/tree/master/examples/simple
// /feedQuery
import { useQuery } from "react-query";
import { fetchComments } from "../api/fetchComments";
import { fetchFeeds } from "../api/fetchFeeds";
export const useFetchFeeds = (category, page) =>
useQuery(category, () => fetchFeeds(category, page));
export const useFetchComments = (id) =>
useQuery(["comments", id], () => fetchComments(id));
react-query 공식 문서
https://react-query.tanstack.com/overview