🎒 React-Query 살펴보기

React-Query는 React앱에서 비동기 로직을 쉽게 다루게 해주는 라이브러리입니다. 그동안 제가 React로 개발할 때 가장 많이 써왔던, Redux와 Saga를 이용해 비동기 관련 로직들을 관리하는 것과 꽤나 다른 관점에서 비동기 로직들을 바라보고, 유용한 기능을 많이 제공하고 있어 매우 흥미로웠습니다. 다만 아직 한국어 자료는 많이 없는 것 같아서, 이 포스팅이 React-Query를 한번 살펴보고 싶으신 분들께 도움이 되었으면 합니다.

포스팅의 내용은 모두 React Query Docs의 내용과 제 사견으로 이루어져 있습니다.
굉장히 러프하게 정리한 내용을 블로그 글로 바로 옮겨서, 기존 블로그 포스팅과 말투도 좀 다르고 이해하기에 부족하거나 빠진 내용이 있을 수 있습니다. 지적 부탁드립니다!

요약 + 느낌

React-Query가 주장하는 Global State 개념

만들어진 동기

초기세팅

yarn add react-query
import { QueryClient, QueryClientProvider, useQuery } from 'react-query'
 
const queryClient = new QueryClient()

export default function App() {
  return (
    <QueryClientProvider client={queryClient}>
      {*/ ...Components */}
    </QueryClientProvider>
  )
}

컨셉

1. 중요한 기본 사항

2. Queries

const { status, data, error, isFetching, isPreviousData } = useQuery(
  ['projects', page],
  () => fetchProjects(page),
  { keepPreviousData: true, staleTime: 5000 }
)

// 예외처리 -> reject쓰지말고 무조건 throw Error
const { error } = useQuery(['todos', todoId], async () => {
  if (somethingGoesWrong) {
    throw new Error('Oh no!')
  }

  return data
 })

3. Query Keys

useQuery(['todo', 5, { preview: true }], ...)
// queryKey === ['todo', 5, { preview: true }]
function Todos({ todoId }) {
   const result = useQuery(['todos', todoId], () => fetchTodoById(todoId))
 }

function Todos({ status, page }) {
   const result = useQuery(['todos', { status, page }], fetchTodoList)
}
 
// 쿼리 요청 함수에서 queryKey에 접근할 수 있다
function fetchTodoList({ queryKey }) {
 const [_key, { status, page }] = queryKey
 return new Promise()
}

4. Parallel

function App () {
   // 이렇게 주루륵 있을 때 걍 다 병렬로 처리된다 => 어떻게 구현한거지... redux batch update 같넹
   const usersQuery = useQuery('users', fetchUsers)
   const teamsQuery = useQuery('teams', fetchTeams)
   const projectsQuery = useQuery('projects', fetchProjects)
   ...
 }
function App({ users }) {
   const userQueries = useQueries(
     users.map(user => {
       return {
         queryKey: ['user', user.id],
         queryFn: () => fetchUserById(user.id),
       }
     })
   )
 }

5. Query Retries

import { useQuery } from 'react-query'
 
 // 재호출 횟수를 옵션으로 커스텀해줄 수 있다.
 const result = useQuery(['todos', 1], fetchTodoListPage, {
   retry: 10, // 에러를 display할 때까지 10번을 더 호출한다.
 })

6. Mutations

function App() {
   const mutation = useMutation(newTodo => 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(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) => {
     // 성공이든 에러든 어쨌든 끝났을 때
   },
 })

7. invalidation

// 캐시가 있는 모든 쿼리들을 invalidate한다.
queryClient.invalidateQueries()

// 'todos'로 시작하는 모든 쿼리들을 invalidate한다.
queryClient.invalidateQueries('todos')

queryClient.invalidateQueries({
   predicate: query =>
     query.queryKey[0] === 'todos' && query.queryKey[1]?.version >= 10,
 })
import { useMutation, useQueryClient } from 'react-query'
 
 const queryClient = useQueryClient()
 
 // 뮤테이션이 성공한다면, 쿼리의 데이터를 invalidate해 관련된 쿼리가 리패치되도록 만든다.
 const mutation = useMutation(addTodo, {
   onSuccess: () => {
     queryClient.invalidateQueries('todos')
     queryClient.invalidateQueries('reminders')
   },
 })
const queryClient = useQueryClient()
 
 const mutation = useMutation(editTodo, {
   onSuccess: data => queryClient.setQueryData(['todo', { id: 5 }], data),
 })
 
 mutation.mutate({
   id: 5,
   name: 'Do the laundry',
 })
 
// 뮤테이션의 response 값으로 업데이트된 data를 사용할 수 있다.
 const { status, data, error } = useQuery(['todo', { id: 5 }], fetchTodoByID)

8. Caching Process

  1. useQuery의 첫번째, 새로운 인스턴스 마운트 ⇒ 만약에 런타임간 최초로 fresh한 해당 쿼리가 호출되었다면, 캐싱하고, 패칭이 끝나면 해당 쿼리를 stale로 바꿈(staleTime:0)
  2. 앱 어딘가에서 useQuery 두번째 인스턴스 마운트 ⇒ 이미 쿼리가 stale이므로 접때 요청때 만들어 놨었던 캐시를 반환하고 리패칭을 함. 이때 캐시도 업데이트.
  3. 쿼리가 언마운트되거나 더이상 사용하지 않을 때 ⇒ 마지막 인스턴스가 언마운트되어 inactive 상태가 되었을때 5분(cacheTime의 기본값)이 지나면 자동으로 삭제한다.

좋아보이는 점

의문점

일단 써보기 전엔 잘 모를 것 같은 점도 정리

Redux Saga 대신 React Query를 사용했을 때 예상 차이

타이핑이 줄어든다 : 유구하게 많은 액션, 액션 크리에이터, Reducer 함수 다 쓸필요 없으니..

그럼에도 디버깅이 용이하다 : Saga에서 그 엄청 많은 액션들을 다 작성하더라도 가성비가 극악까지는 아니었던 이유 중 하나는 디버깅이 쉬워진다는 장점 때문이었다. Redux Devtool 쓰면 비동기 요청의 실행, 성공, 실패까지 액션으로 다 찍히고 store의 diff도 눈으로 확인하기 너무 쉽다. 하지만 React Query도 자체 데브툴을 제공하고(크롬 익스텐션 식으로 나온건 아니고 컴포넌트에 직접 넣어주긴 해야한다) 쿼리의 호출 상태를 바로 브라우저에서 확인이 가능하니, 디버깅을 유용하게 하고자 그 많은 액션을 작성할 필요가 없어졌다.

Redux 자체를 좀 더 취지에 맞게 사용하게 해준다 : 서버 상태와 클라이언트 상태의 특성이 다르니 다르게 관리되어야 한다는 React Query의 주장에 동감하는 편이다. Redux Saga를 쓰며 비동기 로직을 관리하면서도 모든 비동기 요청으로 받아온 데이터를 전부 스토어에 저장해야 하는지, 여러 컴포넌트에서 동시에 데이터가 필요한 경우에는 어떻게 해야 하는지 고민이 될 때가 많았다. 확실히 React Query를 살펴보고 나니, React Query가 이런 서버의 데이터를 좀 더 효율적으로 관리할 수 있는 방법 중 하나가 될 수 있을 것 같다.

Redux는 전역 상태 관리를, React Query는 서버에서 받아온 데이터 관리를 하면서 역할을 분담하는 것이다.

Docs에서는 이렇게 Redux에서 비동기 상태값과 관련된 로직들을 다 드러냈을 때, 동기적으로 업데이트되는 아주 적은 common client state 값만 남을 것이라고 이야기한다. 만약에 그 state값들이 굳이 Redux를 유지해야 하지 않을 정도로 적다면, 앱에서 Redux를 떼버리는 것을 고려할 수 있다고도 주장하는데 맞는 이야기 같다.

React-Query Docs는 매우 친절한 편입니다. 용례를 더 많이 보고 싶으시다면 Docs의 Example 부분을 참조하시면 좋을 것 같습니다.

<< 글 목록으로 돌아가기(클릭!)