React Query

4. 효율적으로 React Query Key 선언하기

yoxxin 2024. 2. 14. 09:30

이 글을 읽고 queryOptions API 글을 읽는 걸 추천합니다


1. 들어가기

Query Keys는 React Query에서 아주 중요한 핵심 개념이다.

`queryKey` 덕분에 라이브러리 내부적으로 데이터를 올바르게 캐싱하고 `queryKey` dependency가 변경될 때 자동으로 refetch 할 수 있다.

또한 mutations 후 수동으로 쿼리 무효화(invalidate)를 하기위해 필요하다.

 

밑줄 친 위 기능들을 하나씩 살펴보고,

각 기능을 효과적으로 수행하기 위해 필요한 효율적인 `queryKey` 선언 방법에 대해 알아보자.

2. `queryKey` 관련 기능

2.1. 데이터 캐싱

내부적으로 Query 캐시는 javascript 객체이다.

key: 직렬화된 `queryKey` , value: Query data 와 메타 정보

queryKey는 배열 `[ ]` 형태이며 배열의 원소는 string, object등이 들어갈 수 있다.

원소들의 순서가 다르면 서로 다른 key로 인식된다. (`['todos', 'kuma']` 와 `['kuma', 'todos']` 는 서로 다르게 취급된다)

다만 원소가 객체일때, 해당 객체 내부 key의 순서가 달라져도 같은 쿼리로 간주된다.

// 쿼리 1
useQuery({
  queryKey: ['todos', { status: 'completed', page: 1 }],
  queryFn: fetchTodos,
})

// 쿼리 2
useQuery({
  queryKey: ['todos', { page: 1, status: 'completed' }],
  queryFn: fetchTodos,
})

// 두 쿼리는 같다

 

다만 key는 같더라도 value가 달라지만 다른 쿼리이다.

// 쿼리 1
useQuery({
  queryKey: ['todos', { status: 'completed', page: 1 }],
  queryFn: fetchTodos,
})

// 쿼리 2
useQuery({
  queryKey: ['todos', { status: 'pending', page: 1 }],
  queryFn: fetchTodos,
})

// 두 쿼리는 다르다.

 

또한 React Query가 캐시 목록에서 `queryKey`에 대응하는 value를 찾으면 이를 사용하기 때문에,

`queryKey`는 반드시 unique 해야한다.

또한 `useQuery` 와 `useInfiniteQuery`에 동일한 키를 사용할 수 없다는 점을 기억하자.

useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodos,
})

// 🚨 useQuery와 queryKey가 동일하기 때문에 동작하지 않는다.
useInfiniteQuery({
  queryKey: ['todos'],
  queryFn: fetchInfiniteTodos,
})

// ✅ 다른 queryKey를 선언하자. 
useInfiniteQuery({
  queryKey: ['infiniteTodos'],
  queryFn: fetchInfiniteTodos,
})

 

2.2. 자동 Refetch

쿼리는 선언적이다.

이는 아무리 강조해도 지나치지 않는 매우 중요한 컨셉이다.

대부분의 사람들은 쿼리를 생각할때, 특히 refetching을 할때 명령적으로 생각한다.

기존 쿼리가 있을때 필터 조건을 걸 수 있을때 이런식으로 사용하고 싶을 수 있다.

function Component() {
  const { data, refetch } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodos,
  })

  // ❓ 매개변수를 넘기고 싶은데.. ❓
  return <Filters onApply={() => refetch(???)} />
}

 

하지만 이렇게 하면 안된다.

이는 refetch의 존재 목적이 아니다. - refetch의 목적은 같은 매개변수로 데이터를 다시 불러오는 것이다.

 

만약 필터와 같이 데이터를 변경하는 상태가 있는 경우, `queryKey`가 변경될 때마다 React Query가 자동으로 refetch 하므로, 해당 상태를 `queryKey`에 넣기만 하면 된다.

즉 쿼리에 필터를 적용하고 싶다면 클라이언트 상태를 변경하기만 하면 된다.

function Component() {
  const [filters, setFilters] = React.useState()
  const { data } = useQuery({
    queryKey: ['todos', filters],
    queryFn: () => fetchTodos(filters),
  })

  // ✅ 로컬 상태를 set하여 query를 가져오자.
  return <Filters onApply={setFilters} />
}

 

`setFilters` 호출을 통해 리렌더링이 되면 다른 `filters`(queryKey)가 쿼리에 전달되어 data가 refetch 된다.

따라서 선언적으로 refetch가 되는 것이다.

선언적인 queryKey에 관해서는 1. 실용적인 React Query 글에도 언급한 적이 있다.

 

2.3. Manual Interaction (수동 상호작용)

`invalidateQueries` 나 `setQueriesData` 같이 개발자가 수동으로 쿼리를 조작할 수 있는 메서드는 호출할 때 Query Fliters를 넣어줘야한다. Query Filters에는 조작할 쿼리를 특정하기위해 `queryKey`가 이용된다.

// 첫 원소가 posts로 시작하는 queryKey를 가진 모든 inactive 쿼리들을 제거한다.
queryClient.removeQueries({ queryKey: ['posts'], type: 'inactive' })

3. 효과적인 React Query Keys

3.1. Colocate

참고)  Kent C. DoddsMaintainability through colocation

모든 `queryKey`를 `/src/utils/queryKeys.ts`에 전역적으로 배치하는게 좋을까?

- src
  - features
    - Profile
      - index.tsx
      - queries.ts
    - Todos
      - index.tsx
      - queries.ts

 

다음과 같이 훅이 가까이 쓰이는 폴더에 `fetch`, `useQuery`, `queryKey`를 같은 파일에 함께 선언해보자.

나(영진)는 보통 `useQuery`를 감싸는 커스텀훅을 내보내고, `queryKey`도 필요하다면 그때 내보낸다.

 

+ 추가

`queryKey`는 관련된 기능의 공통 부모 폴더에 하나의 객체로 선언해두는게 좋아보인다.

3.3 쿼리 키 팩토리 사용하기 참조

3.2. queryKey 배열 원소 선언 순서

일반적인 key -> 구체적인 key 순서대로 선언하자.

다음은 필터링 가능한 list와 각 item별 상세 보기가 가능한 투두리스트의 `queryKey` 예시이다.

['todos', 'list', { filters: 'all' }]
['todos', 'list', { filters: 'done' }]
['todos', 'detail', 1]
['todos', 'detail', 2]

 

이렇게 선언하면 `['todos']`와 관련된 모든 쿼리를 무효화할 수 있고, 정확한 `queryKey`를 알고있다면 특정 쿼리 하나를 타겟팅할 수 있다.

또한 `mutation` 결과(`onSuccess`, `onError`에서)에 따라 쿼리를 업데이트 할 수 있다.

// mutation 결과에 따라 query 업데이트 하기.
function useUpdateTitle() {
  return useMutation({
    mutationFn: updateTitle,
    onSuccess: (newTodo) => {
      // ✅ ['todos', 'detail', newTodo.id]인 캐시 업데이트
      queryClient.setQueryData(
        ['todos', 'detail', newTodo.id],
        newTodo
      )

      // ✅['todos', 'list'] 캐시 내부에 있는 todo.id에 해당하는 todo 업데이트
      queryClient.setQueriesData(['todos', 'list'], (previous) =>
        previous.map((todo) =>
          todo.id === newTodo.id ? newtodo : todo
        )
      )
    },
  })
}

 

하지만 위 방법은 복잡하고, list와 detail의 todo 형식이 많이 다른 경우에는 적용하기 까다로울 수 있다.

대신 모든 `['todos']` 캐시를 무효화하는 방법이 있다. (서버에 재요청)

// 캐시 무효화 하기.
function useUpdateTitle() {
  return useMutation({
    mutationFn: updateTitle,
    onSuccess: (newTodo) => {
      queryClient.setQueryData(
        ['todos', 'detail', newTodo.id],
        newTodo
      )

      // ✅ ['todos', 'list'] 캐시 무효화
      queryClient.invalidateQueries({
        queryKey: ['todos', 'list']
      })
    },
  })
}

 

URL에서 필터를 읽는 등의 방식으로 `queryKey` 를 구성하여 `setQueryData`를 수행할 수도 있다.

function useUpdateTitle() {
  // imagine a custom hook that returns
  // the current filters, stored in the url
  const { filters } = useFilterParams()

  return useMutation({
    mutationFn: updateTitle,
    onSuccess: (newTodo) => {
      queryClient.setQueryData(
        ['todos', 'detail', newTodo.id],
        newTodo
      )

      // ✅ 현재 위치한 list 업데이트
      queryClient.setQueryData(
        ['todos', 'list', { filters }],
        (previous) =>
          previous.map((todo) =>
            todo.id === newTodo.id ? newtodo : todo
          )
      )

      // 🥳 모든 list 무효화
      // 다만 active인 list는 refetch 하지 않는다 (refetchType: 'none')
      queryClient.invalidateQueries({
        queryKey: ['todos', 'list'],
        refetchType: 'none',
      })
    },
  })
}

(사실 위 예시는 이해못했음. 그냥 `invalidateQueries` 만 해도 되지않나? `['todos', 'list', { filters }]`  캐싱을 하고싶은 건가?)

3.3. 쿼리 키 팩토리 사용하기

위 예제에서는 `queryKey`를 수동으로 선언해두었다.

이렇게 하면 더 세부적인 key를 추가하는 경우와 같이 변경하기가 여러워진다.

 

다음과 같이 어떤 기능과 관련된 `queryKey` 들은 객체 하나에 모두 모아두자.

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (filters: string) => [...todoKeys.lists(), { filters }] as const,
  details: () => [...todoKeys.all, 'detail'] as const,
  detail: (id: number) => [...todoKeys.details(), id] as const,
}

 

유연하고 독립적으로 키에 접근할 수 있다.

// 🕺 todo와 관련된 모든 쿼리를 제거한다.
queryClient.removeQueries({
  queryKey: todoKeys.all
})

// 🚀 모든 todo list를 무효화한다.
queryClient.invalidateQueries({
  queryKey: todoKeys.lists()
})

// 🙌 특정 todo 하나를 prefetch 한다.
queryClient.prefetchQueries({
  queryKey: todoKeys.detail(id),
  queryFn: () => fetchTodo(id),
})

 

각 기능별로 쿼리 키를 선언해두면 서버 상태로 한눈에 보기 쉬운 장점이 있을 것 같다.

 

Reference

https://tkdodo.eu/blog/effective-react-query-keys