이번 포스팅에서는 React Query 공식문서를 참고하여 React Query의 컨셉과 제공 기능에 대해 정리해보고, 어떤 장점이 있는지 알아보려 한다.
이 포스팅이 React Query를 처음 공부하시는 분들께 가이드 문서처럼 도움이 되었으면 좋겠다. 😉
💡 2022-05-01일 최종 업데이트 되었습니다.
React-Query가 등장한 배경에 대해 살펴보자. 클라이언트 개발을 하다보면 서버에서 받아온 데이터를 이용하여 화면을 구성하는 경우가 많다. 이 중 다양한 곳에 사용되는 정보들은 주로 Redux와 같은 라이브러리를 이용해서 전역 상태로 관리되었다.
Redux는 기본적으로 비동기 통신을 통한 상태 업데이트를 지원하지 않기 때문에 이를 위해선 Redux-thunk, Redux-saga 같은 미들웨어 라이브러리를 함께 사용해야 한다. 이렇게 되면 자연스레 상태를 관리하기 위해 작성되는 보일러 플레이트 코드가 늘어나고, 전역 상태 공간은 점점 더 비대해지고 복잡해 진다.
또한 API 통신을 담당하는 코드와 전역 상태를 담당하는 코드가 한 곳에 뭉쳐 있다 보니 경계가 모호해지고 관심사의 분리 또한 어려워진다.
React-Query는 이러한 문제를 해결하기 위해 나온 라이브러리이다. React-Query는 데이터 fetching 및 캐싱이 클라이언트 상태 관리와 실제로 다른 문제 집합이라는 가정에서 출발한다.
따라서 React query에서는 상태를 서버 상태와 클라이언트 상태 의 두가지 개념으로 분리하며, 리액트 내부 state와 context를 사용하여 서버 상태를 관리한다.
즉, 서버 상태를 React다운 방식으로 처리하기 위한 라이브러리라고 볼 수 있다.
React Query를 사용하면 서버의 값을 클라이언트에 가져오거나, 캐싱, 데이터 값 업데이트, 에러핸들링 등 비동기 과정을 더욱 편하게 사용할 수 있다. 조금 더 자세하게는 아래와 같은 기능을 제공한다.
- 데이터 캐싱
- 동일 데이터에 대한 단일 요청 처리
- 오래된 데이터 refetch
- 백그라운드 데이터 업데이트
- 데이터가 오래된 경우 파악
- 인피니트 스크롤
위에서 언급한 개념들을 이해하기 위해서는 React Query의 기본 개념에 대한 배경지식이 필요하므로 중요한 부분만 살펴보자.
참고로 React-Query의 공식문서는 굉장히 친절한 편이니 배우고자 하시는 분들은 이 포스팅을 읽고 꼭 한번 읽어보시길 추천드린다.
React-query에서 비동기 fetch API를 Query라고 부른다. 각각의 Query는 4개의 상태를 가지며, 상태에 따라 해당 query 요청이 다시 들어왔을때 어떠한 액션을 취할지가 결정된다.
새롭게 추가된 쿼리 인스턴스이며 active 상태로 시작한다. 기본 staleTime이 0이기 때문에 아무 설정이 없으면 호출이 끝나고 바로 stale 상태로 변하게 된다.
staleTime을 늘려줄 경우 fresh한 상태가 유지되는데, 이때는 쿼리가 다시 마운트되도 패칭이 발생하지 않고 기존의 fresh한 값을 반환한다.
말 그대로 요청을 수행하는 중인 쿼리이다.
쿼리 인스턴스가 존재하지만 이미 패칭이 완료된 쿼리이다. 특정 쿼리가 stale된 상태에서 같은 쿼리 마운트를 시도한다면 캐싱된 데이터를 반환하면서 리패칭을 시도한다.
비활성화된 쿼리로, 현재 인스턴스가 하나도 없는 쿼리이다. 비활성화 된 이후에도 cacheTime 동안 캐시된 데이터가 유지된다. 이후 cacheTime이 지나면 가비지 콜렉터에 의해 제거된다.
기본적으로 다음 4가지 경우에 리패칭이 일어난다
이제 react query가 제공하는 API들 위주로 살펴보자. 기본적으로는 서버 데이터를 조회할때 사용되는 useQuery 훅과 서버의 상태를 변경할때(추가, 삭제, 업데이트) 사용되는 useMutation 훅을 가장 많이 사용하게 된다.
useQuery hook은 서버 데이터를 조회할때 사용할 수 있는 API이다.
function App () {
// The following queries will execute in parallel
const usersQuery = useQuery('users', fetchUsers)
const teamsQuery = useQuery('teams', fetchTeams)
const projectsQuery = useQuery('projects', fetchProjects)
...
}
첫번째 파라미터로 unique key가 들어가고, 두번째 파라미터로 api 호출 함수를 전달한다. 첫번째 파라미터로 설정한 key를 다른 컴포넌트에서도 사용한다면 동일한 쿼리를 호출 가능하다. unique key로는 string과 배열이 전달 가능하다.
useQuery hook을 통해 받아온 결과값은 data로 받아와 사용할 수 있다. 추가적으로, fetch 상태를 알 수 있는 IsLoading, isError값도 같이 받아올 수 있으며 data rendering시 이용할 수 있다.
api를 병렬로 요청해야 할 경우, useQuery를 여러번 실행하면 된다.
export function Todos() {
const { isLoading, isError, data, error } = useQuery('todos', fetchTodoList);
if (isLoading) {
return <span>Loading...</span>;
}
if (isError) {
return <span>Error: {error.message}</span>;
}
// We can assume by this point that `isSuccess === true`
return (
<ul>
{data.map((todo) => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
);
}
redner마다 fetch 해야 하는 API 횟수가 달라지는 경우, useQuries 훅을 이용할 수 있다.
function App({ users }) {
const userQueries = useQueries(
users.map(user => {
return {
queryKey: ['user', user.id],
queryFn: () => fetchUserById(user.id),
}
})
)
}
쿼리가 동기적으로 실행해야 하는 경우, useQuery의 enabled option을 이용하여 진행할 수 있다.
function App(){
const { data: user } = useQuery(['user', email], getUserByEmail);
const userId = user?.id
const { isIdle, data: projects } = useQuery(
['projects', userId],
getProjectsByUser,
{
// 해당 query는 userId가 존재할때까지 실행되지 않는다.
enabled: !!userId,
}
)
}
enabled option을 통해 특정 data가 존재하면 Query가 시작하도록 설정 가능하다. 그 전까지 해당 query의 isIdle 값은 true가 된다.
query의 로딩 상태를 isLoading 값을 통해 알 수 있지만, query가 background에서 fetching될 때의 값은 isFetching을 통해 확인할 수 있다.
function Todos() {
const { status, data: todos, error, isFetching } = useQuery(
'todos',
fetchTodos
)
return status === 'loading' ? (
<span>Loading...</span>
) : status === 'error' ? (
<span>Error: {error.message}</span>
) : (
<>
{isFetching ? <div>Refreshing...</div> : null}
<div>
{todos.map(todo => (
<Todo todo={todo} />
))}
</div>
</>
)
}
특정 Query가 아니라 모든 Query에 대한 refetching을 체크하고 싶을때는, useIsFetching hook을 사용할 수 있다.
import { useIsFetching } from 'react-query'
function GlobalLoadingIndicator(){
const isFetching = useIsFetching();
return isFetching ? (
<div> Queries are fetching in the background...</div>
) : null
}
기본적으로 useQuery hook 호출 시 data fetching이 자동으로 일어난다. query가 자동으로 진행되는것을 방지하고 싶을때, 앞서 언급한 enable option에 false 값을 넣을 수 있다.
const {
isIdle,
isLoading,
isError,
data,
error,
refetch,
isFetching,
} = useQuery('todos', fetchTodoList, {
enabled: false,
})
return (
<>
<button onClick={() => refetch()}>Fetch Todos</button>
{isIdle ? (
'Not ready...'
) : isLoading ? (
<span>Loading...</span>
) : isError ? (
<span>Error: {error.message}</span>
) : (
<>
<ul>
{data.map(todo => (
<li key={todo.id}>{todo.title}</li>
))}
</ul>
<div>{isFetching ? 'Fetching...' : null}</div>
</>
)}
</>
)
}
이후 fetch를 진행하고 싶을때, useQuery hook이 반환하는 refetch 함수를 이용하여 진행 가능하다.
아무 옵션도 주지 않았을 시 React Query는 기본적으로 3회 retry를 진행한다. 이는 option 값을 변경함으로써 수정 가능하다.
import { useQuery } from 'react-query'
const result = useQuery(['todos', 1], fetchTodoListPage, {
retry: 10, // error를 띄우기 전 10번 retry 한다.
retryDelay: attemptIndex => Math.min(1000 * 2 ** attemptIndex, 30000),
})
전에 Query된 데이터가 존재할때, 기존 데이터를 우선적으로 보여주고 background에서 데이터를 갱신하고 싶다면 keepPreviousData option을 이용할 수 있다.
function Todos() {
const [page, setPage] = React.useState(0)
const fetchProjects = (page = 0) => fetch('/api/projects?page=' + page).then((res) => res.json())
const {
isLoading,
isError,
error,
data,
isFetching,
isPreviousData,
} = useQuery(['projects', page], () => fetchProjects(page), { keepPreviousData : true })
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : isError ? (
<div>Error: {error.message}</div>
) : (
<div>
{data.projects.map(project => (
<p key={project.id}>{project.name}</p>
))}
</div>
)}
<span>Current Page: {page + 1}</span>
<button
onClick={() => setPage(old => Math.max(old - 1, 0))}
disabled={page === 0}
>
Previous Page
</button>{' '}
<button
onClick={() => {
if (!isPreviousData && data.hasMore) {
setPage(old => old + 1)
}
}}
// Disable the Next Page button until we know a next page is available
disabled={isPreviousData || !data?.hasMore}
>
Next Page
</button>
{isFetching ? <span> Loading...</span> : null}{' '}
</div>
)
}
이 옵션은 페이지네이션을 구현할 때 유용하게 사용할 수 있다. 캐시되지 않은 페이지를 가져올 때 화면에서 목록이 사라지는 깜빡임 현상을 방지할 수 있다. 또한 isPreviousData 값으로 현재의 쿼리 키에 해당하는 값인지 확인할 수 있다.
intial data와 비슷하게 원래 data가 있는 것 처럼 동작하지만, data는 cache에 남지 않게 된다.
function Todos() {
const placeholderData = useMemo(() => generateFakeTodos(), [])
const result = useQuery('todos', () => fetch('/todos'), { placeholderData })
}
useMutation hook의 경우 server side에 effect가 있는 query에 사용한다. 즉, 데이터 조회 작업 이외의 생성/갱신/삭제에 해당하는 query에 사용한다.
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>
)
}
useMutation hook도 useQuery와 비슷하게 비동기 로직을 전달받고, {isLoading, isError, isSucess, mutate} 이 담긴 object를 반환한다. mutate 메서드를 이용해 API call을 진행하면 된다.
일반적으로 데이터 추가/삭제/갱신 작업이 일어나면 새로운 변경 사항을 설명하기 위해 데이터를 다시 가져와야 하는 경우가 많다.
주로 mutate로 데이터 추가/삭제/갱신 작업을 진행 후 invalidQuries로 기존 조회에 사용했던 query를 무효화함으로써 자동 데이터 갱신을 진행할 수 있다.
import { useMutation, useQueryClient } from 'react-query'
const queryClient = useQueryClient()
// mutation 성공 시 onSuccess callback 실행됨
const mutation = useMutation(addTodo, {
onSuccess: () => {
queryClient.invalidateQueries('todos')
queryClient.invalidateQueries('reminders')
},
})
지금까지 공식문서를 살펴보며 React Query의 컨셉과 제공 기능 대해 알아보았다. 최근에는 앞서 언급한 Redux도 RTK Query라는 라이브러리를 배포하며 비슷한 변화를 보여주고 있다. 전반적인 생태계 흐름이 클라이언트 상태와 서버 상태를 분리하는 움직임으로 가고 있는 것 같다.
살펴본 내용 외에도 tkdodo님의 블로그에서도 실무적인 관점에서 쓰여진 React Query 시리즈가 있으니 읽어 보시는 것을 추천드린다.