본문 바로가기

FrontEnd

리액트 쿼리 : Query Function Context로 queryFn 타이핑

반응형

원문 : https://tkdodo.eu/blog/leveraging-the-query-function-context

요점

  • 인라인 함수를 사용하지 마세요.
  • Query Function Context를 활용하세요.
  • 객체 키를 생성하는 쿼리 키 팩토리를 사용하세요.

들어가며

인라인 queryFn 예제를 살펴봅시다

type State = 'all' | 'open' | 'done'
type Todo = {
  id: number
  state: TodoState
}
type Todos = ReadonlyArray<Todo>

const fetchTodos = async (state: State): Promise<Todos> => {
  const response = await axios.get(`todos/${state}`)
  return response.data
}

export const useTodos = () => {
  // imagine this grabs the current user selection
  // from somewhere, e.g. the url
  const { state } = useTodoParams()

  // ✅ The queryFn is an inline function that
  // closures over the passed state
  return useQuery(['todos', state], () => fetchTodos(state))
}

간단한 예에서는 훌륭하게 동작하지만 많은 매개변수를 포함하게 될 시 문제가 됩니다.

대규모 애플리케이션에 필터 및 정렬 옵션이 많은 것은 이상한 일이 아니며

최대 10개의 매개변수가 전달되는 것도 보았습니다.

 

쿼리에 정렬을 추가한다고 가정해 보겠습니다.

저는 아래에서 위로 접근하는 것을 좋아합니다.

queryFn으로 시작하여 컴파일러가 다음에 변경해야 할 사항을 알려줍니다.

type Sorting = 'dateCreated' | 'name'
const fetchTodos = async (
  state: State,
  sorting: Sorting
): Promise<Todos> => {
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}

이것은 fetchTodos를 호출하는 우리의 커스텀 훅에서 확실히 오류를 발생시킬 것입니다.

아래 코드에서 문제를 발견해 보세요

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  // 🚨 can you spot the mistake ⬇️
  return useQuery(['todos', state], () => fetchTodos(state, sorting))
}
queryKey가 실제 종속성과 동기화되지 않았고 빨간색 물결선이 이에 대해 비명을 지르지 않습니다. 😔
더 복잡해지면 추적하기 어려운 문제가 발생할 수 있습니다.
React가 react-hooks/exhaustive-deps eslint rule 규칙을 제공하는 이유가 있습니다.
React Query는 위 문제를 다음 방법으로 해결합니다.

QueryFunctionContext

QueryFunctionContext는 queryFn에 인수(artument)로 전달되는 객체입니다.

infinite queries로 작업하면 주로 사용합니다.

// this is the QueryFunctionContext ⬇️
const fetchProjects = ({ pageParam = 0 }) =>
  fetch('/api/projects?cursor=' + pageParam)

useInfiniteQuery('projects', fetchProjects, {
  getNextPageParam: (lastPage) => lastPage.nextCursor,
})
React Query는 해당 객체를 사용하여 쿼리에 대한 정보를 queryFn에 주입합니다.
infinite queries의 경우 getNextPageParam의 반환 값이 pageParam으로 주입됩니다.
 
컨텍스트에는 이 쿼리에 사용되는 queryKey도 포함되어 있습니다.
(그리고 컨텍스트에 더 멋진 항목을 추가할 예정입니다).
const fetchTodos = async ({ queryKey }) => {
  // 🚀 we can get all params from the queryKey
  const [, state, sorting] = queryKey
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  // ✅ no need to pass parameters manually
  return useQuery(['todos', state, sorting], fetchTodos)
}​

 

이 접근 방식을 사용하면 queryFn에서 추가 매개변수를 queryKey에 추가하지 않고는 사용할 수 없습니다. 🎉

How to type the QueryFunctionContext (QueryFunctionContext 타이핑)

queryFn을 인라인하면 타입이 올바르게 추론된 것을 볼 수 있습니다.
export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  return useQuery(
    ['todos', state, sorting] as const,
    async ({ queryKey }) => {
      const response = await axios.get(
        // ✅ this is safe because the queryKey is a tuple
        `todos/${queryKey[1]}?sorting=${queryKey[2]}`
      )
      return response.data
    }
  )
}​
이것은 훌륭하지만 여전히 많은 결함이 있습니다.
  • 여전히 클로저에 있는 모든 것을 사용하여 쿼리를 작성할 수 있습니다.
  • 위의 방법으로 url을 빌드하기 위해 queryKey를 사용하는 것은 모든 것을 문자열화할 수 있기 때문에 여전히 안전하지 않습니다.

Query Key Factories

키를 빌드하기 위한 타입 안전한 쿼리 키 팩토리가 있는 경우,

해당 팩토리의 리턴 타입을 사용하여 QueryFunctionContext를 입력할 수 있습니다.

const todoKeys = {
  all: ['todos'] as const,
  lists: () => [...todoKeys.all, 'list'] as const,
  list: (state: State, sorting: Sorting) =>
    [...todoKeys.lists(), state, sorting] as const,
}

const fetchTodos = async ({
  queryKey,
}: 
// factory 함수의 리턴값인 키만 허용합니다 🤯 🤯
QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
  const [, , state, sorting] = queryKey
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  // ✅ 쿼리키 팩토리로 키를 빌드합니다.
  return useQuery(todoKeys.list(state, sorting), fetchTodos)
}

 

위의 예에서는 쿼리키를 키 팩토리의 list 함수가 반환하는 것과 동일하게 설정합니다.
const assertions을 사용하기 때문에 모든 키는 엄격하게 타입이 지정된 튜플이 될 것입니다.
따라서 해당 구조를 따르지 않는 키를 사용하려고 하면 타입 오류가 발생합니다.
 

객체 쿼리 키 

배열의 구조분해는 named property를 가져올 때 위험할 수 있습니다. undefined인지 체크해주지 않습니다.
const [, , state, sorting] = queryKey​
명명된 분해를 사용할 수 있기 때문에 객체는 이 문제를 정말 잘 해결합니다.
또한 쿼리 무효화를 위한 퍼지 일치(추상적인 키를 입력할 수록 매칭 범위가 커지는 일치)가
배열의 경우와 객체에 대해 동일하게 작동하기 때문에
쿼리 키로서 단점이 없습니다.
작동 방식에 관심이 있다면 partialDeepEqual 함수를 살펴보십시오.

따라서 현재 내가 알고 있는 쿼리 키를 구성하는 방법은 다음과 같습니다.

const todoKeys = {
  // ✅ all keys are arrays with exactly one object
  all: [{ scope: 'todos' }] as const,
  lists: () => [{ ...todoKeys.all[0], entity: 'list' }] as const,
  list: (state: State, sorting: Sorting) =>
    [{ ...todoKeys.lists()[0], state, sorting }] as const,
}

const fetchTodos = async ({
  // ✅ extract named properties from the queryKey
  queryKey: [{ state, sorting }],
}: QueryFunctionContext<ReturnType<typeof todoKeys['list']>>) => {
  const response = await axios.get(`todos/${state}?sorting=${sorting}`)
  return response.data
}

export const useTodos = () => {
  const { state, sorting } = useTodoParams()

  return useQuery(todoKeys.list(state, sorting), fetchTodos)
}
개체 쿼리 키는 순서가 없기 때문에 퍼지 일치 기능을 더욱 강력하게 만듭니다.
배열 접근 방식을 사용하면 할 일과 관련된 모든 것, 모든 할일 목록 또는 특정 필터가 있는 할일 목록을 처리할 수 있습니다.
// 🕺 remove everything related to the todos feature
queryClient.removeQueries([{ scope: 'todos' }])

// 🚀 reset all todo lists
queryClient.resetQueries([{ scope: 'todos', entity: 'list' }])

// 🙌 invalidate all lists across all scopes
queryClient.invalidateQueries([{ entity: 'list' }])​
이는 계층 구조의 여러 범위가 있고,하위 범위에 속한 모든 항목과 일치시키려는 경우에 매우 유용할 수 있습니다.

이렇게 할 만한 가치가 있나요?

항상 그렇듯이 상황에 따라 다릅니다.
나는 이 접근 방식을 사랑해왔지만(이것이 여러분과 공유하고 싶은 이유입니다),
복잡성과 타입 안전성 사이에는 확실히 절충점이 있습니다.
 
키 팩토리 내에서 쿼리 키를 구성하는 것은 약간 더 복잡하며, (queryKeys는 여전히 최상위 수준에서 배열이어야 하기 때문에)
키 팩토리의 반환 타입에 따라 컨텍스트를 입력하는 것도 간단하지 않습니다.
 
팀이 작고 API 인터페이스가 얇거나 일반 JavaScript를 사용하는 경우 해당 방법을 사용하고 싶지 않을 수 있습니다.
평소와 같이 특정 상황에 가장 적합한 도구와 접근 방식을 선택하십시오. 🙌
반응형