React Query는 리액트 환경에서 서버 상태 및 비동기 데이터를 관리하기 위한 라이브러리이다.
기존에 Redux를 통해 API 통신을 수행하거나 비동기 데이터를 관리할 때엔 여러 불편함이 있을 수밖에 없었다. 왜냐하면 Redux는 비동기 데이터를 다루기 보단 '전역 상태 관리'를 목적으로 개발된 라이브러리이기 때문이다.
Redux에서는 비동기 요청을 처리하기 위해 redux-thunk
나 redux-saga
와 같은 복잡한 문법의 추가 라이브러리를 필요로 했고, 데이터의 상태(loading, success, failure 등)에 따라 또 다시 수많은 보일러 플레이트 코드를 작성해야 했다.
하지만 React Query는 그와 달리 처음부터 비동기 데이터를 다루기 위해 개발된 라이브러리이므로 그런 면에서 훨씬 더 편리하고, 깔끔하고, 강력하다. 게다가 단순히 서버 상태를 가져오거나 변경하는 것을 넘어 데이터 캐싱, 지속적인 동기화 및 업데이트 등의 기능을 제공하기도 한다.
HTTP GET 요청과 같이 외부 데이터를 불러오고자 할 때는 useQuery
hook을 통해 쿼리를 호출할 수 있다.
본격적인 쿼리 사용법을 배우기 전에, 다음 사항들을 먼저 알아두자.
useQuery
를 사용해 가져온 쿼리 인스턴스는 기본적으로 캐시된 데이터를 'stale' 하다고 여김.
- state == '만료됨', '더는 유효하지 않음'
- stale한 쿼리는 다음과 같은 상황에서 자동으로 refetching됨.
- 해당 쿼리의 새 인스턴스가 마운트 되었을 때
- 윈도우 창이 다시 포커싱 되었을 때
- 네트워크가 재연결 되었을 때 등...
useQuery(queryKey, queryFn, config)
queryKey
: 내부적으로 쿼리를 관리하기 위한 unique하고 serializable한 값. queryFn
: Promise를 반환하는 비동기 함수. 외부 데이터를 직접 요청하는 코드가 포함된다. 요청 성공 시엔 응답된 값을 넣어주고, 에러 발생 시엔 throw
문을 통해 반드시 에러를 던져줘야 한다.fetch
API는 HTTP 요청에 실패하더라도 axios
와 달리 자동으로 에러를 던지지 않음에 주의할 것. (네트워크 오류 시에만 에러 발생){ queryKey === [_key, { status, page }] }
인자를 통해 쿼리 키를 참조할 수도 있다.config
: 쿼리의 옵션을 지정한다. 자세한 사항은 밑에 따로 적어두었다.참고로, 객체 형식으로도 인자를 전달할 수 있다.
useQuery({ queryKey: ['todo', 7], queryFn: fetchTodo, ...config })
useQuery
로 불러온 쿼리 결과는 다음과 같이 이용할 수 있다.
const { isLoading, isError, data, error } = useQuery('todos', fetchTodoList)
// 또는 const { state, data, error } = ...도 가능
// state === 'loading' | 'error' | 'success'
if (isLoading) {
return <span>Loading...</span>
}
if (isError) {
return <span>Error: {error.message}</span>
}
return (
<ul>
// data에는 요청 성공 시의 응답 값이 들어있다
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
)
useQuery
의 반환값으로 ifFetching
변수를 받아와서 쓰면 된다.useIsFetching
hook을 이용하자.queryClient.prefetchQuery(queryKey, queryFn)
를 통해 특정 쿼리를 Prefetching 하는 것도 가능하다.useQueries({ queryKey, queryFn, config }[])
useQuery
를 직접 반복하여 쓰는 대신 useQueries
를 이용할 수도 있다.queryKey
및 queryFn
으로 이루어진 객체의 배열을 받아서, 쿼리 결과가 들어있는 배열을 반환한다.useInfiniteQuery(queryKey, queryFn, config)
useInfiniteQuery
hook을 사용할 수 있다.useQuery
의 주요 옵션(config
)들enable
이 옵션은 쿼리의 활성화 여부를 결정한다. 만약 이 값으로 false
를 할당한다면 해당 쿼리의 status
는 캐시된 데이터의 존재 여부에 따라 'success'
또는 'idle'
로 고정되며, 자신의 refetch
메서드를 사용하지 않는 한 (자동으로든 남에 의해서든) refetching 되지 않는다.
Dependent query: 이전에 수행을 마친 쿼리에게 의존하는 쿼리를 뜻한다.
enabled
값으로 이전 쿼리 결과를 넣어주면, 그 쿼리 결과가 도출될 때까지 해당 쿼리가 실행되지 않는 식이다.
retry
react-query는 기본적으로 쿼리가 실패했을 때 3번까지 해당 쿼리를 재시도하는데 이 항목을 통해 재시도 횟수를 임의로 지정할 수 있다. (false
, true
, 숫자 등)
keepPreviousData
쿼리 키를 바꿔서 쿼리를 요청할 시 이전 쿼리에 대한 결과를 현재 쿼리 결과를 완전히 받아올 때까지 유지시킬 수 있다.
이 옵션은 Pagination을 구현할 때 주로 쓰인다. 새 데이터를 fetching 하는 도중에도 이전 결과가 그대로 유지되므로 불필요한 화면 전환 없이 부드럽게 다음 페이지를 보여줄 수 있기 때문이다.
또한 useQuery
의 반환값 중 하나인 isPreviousData
를 통해 이전 쿼리에 대한 결과가 존재하는지 여부를 알 수 있다.
placeholderData
쿼리의 실제 데이터 fetching이 이뤄지기 전에 미리 보여줄 더미 데이터를 지정할 수 있다. 값 자체 또는 값을 반환하는 함수를 넣어주며, 함수 내부에서 queryClient
를 통해 외부 쿼리의 데이터를 불러와 이용할 수도 있다.
initialData
placeholderData
와 비슷하나, 넣어준 데이터에 대해 캐싱이 이루어진다. 즉, 더미 데이터가 아닌 실제 데이터의 첫 요청을 대신하고자 할 때 주로 사용한다.
staleTime
옵션을 함께 사용하여 초기 데이터가 stale
상태로 취급되기 전까지의 시간을 지정해줄 수 있다. (0이면 즉시 refetching 발동)
staleTime
해당 쿼리의 데이터가 stale로 취급되기까지의 시간을 지정한다. 기본값은 0이며 Infinity도 줄 수 있다.
suspense
해당 쿼리의 suspense 모드 활성화 여부를 지정한다. 이 값이 true
이면 useErrorBoundary
옵션도 기본적으로 true
가 된다.
const UserProfile = () => {
const { data } = useQuery(queryKey, queryFn, { suspense: true });
...
}
const User = () => (
<ErrorBoundary FallbackComponent={UserProfileFallback}>
<Suspense fallback={<UserProfileLoading />}>
<UserProfile />
</Suspense>
</ErrorBoundary>
);
이 옵션과 <Suspense>
, <ErrorBoundary>
를 이용하면 선언형 컴포넌트 방식으로 화면을 구성할 수 있다. 자세한 내용은 밑 게시글을 참고하자.
외부 데이터를 그대로 가져오기만 하는 query와 달리, 외부 데이터를 변경(create/update/delete)하거나 'Side Effect'를 주고 싶을 땐 useMutation
hook을 이용한다.
Side Effect가 항상 나쁜 의미(부작용)로 쓰이는 것은 아니다.
단순히 어떤 주된 작업 후에 따르는 (의도적인) 부수적 효과를 뜻할 때 쓰이기도 한다.
useMutation(queryFn, config)
queryFn
: 서버 요청을 보낼 비동기 함수를 정의한다.config
: 옵션 객체로, onMutate
/onError
/onSuccess
/onSettled
메서드를 포함하여 mutation 과정에 따른 사이드 이펙트를 만들어 낼 수 있다.useMutation
호출 후 반환된 객체 mutation
의 비동기 메서드 mutate
를 통해 쿼리를 수행할 수 있으며, status
, error
, data
등의 값을 사용할 수 있다는 점은 useQuery
와 동일하다.
function App() {
// mutation 객체 생성
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={() => {
// mutate 메서드를 통해 인자 전달
mutation.mutate({ id: new Date(), title: 'Do Laundry' })
}}
>
Create Todo
</button>
</>
)}
</div>
)
}
mutate
호출 시의 인자는 단일 값 또는 객체이며, useMutation
에서 정의한 queryFn
의 인자로 전달된다.mutation.reset()
을 통해 error
및 data
를 초기화할 수 있다.onMutate
/onError
/onSuccess
/onSettled
메서드는 useMutation
이 아닌 mutate
의 인자로도 넣어줄 수 있다. (= component-specific side effect)queryClient
는 쿼리의 캐시와 상호작용하는 객체이며, 유용한 메서드들을 여럿 제공한다. 우리가 react-query를 통해 만든 모든 쿼리들과 연결되어 있는 객체라고 생각하면 될 것 같다. 밑의 예시처럼 쓸 수 있다.
import { QueryClient } from 'react-query'
const queryClient = new QueryClient({
defaultOptions: { // 옵션 기본값 지정
queries: {
staleTime: Infinity,
},
},
})
await queryClient.prefetchQuery('posts', fetchPosts) // Prefetch 메서드
어떠한 이유로 쿼리의 데이터가 (stale로 지정되기 전에) 유효하지 않게 되었을 때, queryClient
의 invalidateQueries
메서드를 사용하여 모든 또는 특정 쿼리를 무효화(invalidate)시킬 수 있다.
이 경우 해당 쿼리는 stale로 취급받게 되며, 만약 컴포넌트에서 사용되고 있다면 새로운 데이터를 다시 받아오기 위해 refetching이 발동될 것이다.
queryClient.invalidateQueries(queryKey, option)
queryKey
: 무효화시킬 쿼리의 키를 적는다. 해당 키가 포함되는 모든 쿼리를 무효화시키며, 생략 시 캐시의 모든 쿼리를 무효화시킨다.option
으로 { exact: true }
를 포함하면 queryKey
와 정확히 일치하는 쿼리만을 무효화시킨다.queryKey
대신 조건을 직접 정의하여 쿼리를 선택할 수도 있다. 이 경우 predicate
메서드가 사용된다.
queryClient.invalidateQueries({
predicate: query =>
// querykey에 포함된 version이 10 이상인 것만 무효화
query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
})
// 무효화!
const todoListQuery = useQuery(['todos', { version: 20 }], fetchTodoList)
// 무효화!
const todoListQuery = useQuery(['todos', { version: 10 }], fetchTodoList)
// 무효화되지 않음
const todoListQuery = useQuery(['todos', { version: 5 }], fetchTodoList)
그렇다면 정확히 언제 query invalidation이 필요할까?
만약 서버에 어떤 데이터가 존재하고, 우리가 useQuery
로 해당 데이터를 불러와 컴포넌트를 통해 보여주고 있다고 하자.
사용자 쪽에서 useMutation
을 호출하여 서버 데이터를 변경한다면, 기존에 보여주던 데이터 중 몇몇은 더이상 유효하지 않게 될 것이다.
이때 query invalidation을 적용하여 기존에 보여주던 쿼리들을 무효화하고 새로 데이터를 받아오도록 하면 데이터 일관성을 보장할 수 있다.
import { useMutation, useQueryClient } from 'react-query'
const queryClient = useQueryClient()
// When this mutation succeeds, invalidate any queries with the `todos` or `reminders` query key
const mutation = useMutation(addTodo, {
onSuccess: () => {
queryClient.invalidateQueries('todos')
queryClient.invalidateQueries('reminders')
},
})
성공 여부와 상관 없이 mutation 실행 시마다 무효화를 하고 싶다면 onSuccess
대신 onSettled
메서드에 적어주자. (이 방법이 더 안정적인 것 같다)
queryClient.setQueryData(queryKey, data)
만약 서버 데이터를 업데이트 한 후 다시 refetching을 하지 않고도 데이터를 동기화하고 싶다면 mutation의 응답을 이용하면 된다.
mutation 요청이 성공했다면 onSuccess
메서드의 data
인수로 mutation의 결과가 전달된다. 이때 queryClient
의 setQueryData
메서드를 통해 쿼리 데이터 값을 직접 변경해준다.
const mutation = useMutation(editTodo, {
onSuccess: data => {
queryClient.setQueryData(['todo', { id: 5 }], data)
}
})
mutation.mutate({
id: 5,
name: 'Do the laundry',
})
// mutation 성공 후 쿼리 결과가 업데이트됨
const { status, data, error } = useQuery(['todo', { id: 5 }], fetchTodoById)
queryClient.getQueryData(queryKey)
mutation이 중간에 실패하는 경우를 대비해 onMutate
내부에서 queryClient.getQueryData(queryKey)
를 호출하여 기존 쿼리 값을 불러와 리턴한 후, onError
에서 이를 다시 queryClient.setQueryData(queryKey, data)
으로 되돌려주는 update rollback 방식을 구현할 수 있다.
queryClient.cancleQueires(queryKey)
쿼리 요청이 지나치게 오래 걸리는 경우, 수동으로 쿼리를 취소하기 위해 이 메서드를 사용할 수도 있다.
취소된 쿼리는 이전 상태로 되돌아간다.
unmounted 또는 unused 쿼리를 자동으로 취소하려면
queryFn
의 인자signal
을 HTTP API에 옵션으로 넘겨주면 된다.
처음엔 기본적인 기능만 간단히 적어두려고 했는데, 공식 문서를 보다보니 중요해보이는 것들이 꽤 많아서 글이 좀 길어졌다.
지금까지는 나도 프로젝트를 할 때마다 Redux를 통해 비동기 데이터를 관리했었는데,
외부에서 불러온 상태와 내부에서 쓰는 상태가 잘 구분되지 않는다거나, 코드가 필요 이상으로 장황해지는 점 등등 여러 면에서 불편함을 느꼈었기에 React Query가 아주 매력적으로 느껴졌다. (심지어 위 단점을 다 해소하면서도 캐싱 등 추가적인 강력한 기능들이 너무 멋졌다)
어서 다음 프로젝트에 써보고 싶다!
Reference