본문 바로가기

FrontEnd

리액트 쿼리 : 타입스크립트

반응형

원문 보기 : https://tkdodo.eu/blog/react-query-and-type-script

 

React Query and TypeScript

Combine two of the most powerful tools for React Apps to produce great user experience, developer experience and type safety.

tkdodo.eu

Photo by   hue12 photography

리액트 쿼리의 동적이며 unopinated인 특성 때문에 ts와 사용하기 어려운 부분들이 존재합니다. 좀 더 알아봅시다.

Generics

React Query는 Generics를 많이 사용합니다.

라이브러리가 실제로 데이터를 가져오지 않고 API가 반환하는 데이터의 타입을 알 수 없기 때문에 필요합니다.

 

공식 문서(official docs)의 TypeScript 섹션은 그리 길지 않으며,

useQuery가 호출할 때 예상하는 Generics를 명시적으로 지정하도록 알려줍니다.

아래 예제의 경우 다음과 같이 타입을 추론합니다

  • 리턴 데이터의 타입은 Group[] | undefined
  • 오류의 타입은 Error | undefined
function useGroups() {
  return useQuery<Group[], Error>('groups', fetchGroups)
}

하지만 useQuery훅에는 점점 많은 제네릭이 추가되어 현재 4개가 있습니다.


The four Generics

다음은 useQuery 훅의 현재 정의입니다.
export function useQuery<
  TQueryFnData = unknown,
  TError = unknown,
  TData = TQueryFnData,
  TQueryKey extends QueryKey = QueryKey
>
  • TQueryFnData: queryFn에서 반환된 타입입니다. 이전 예제에서 Group[]입니다.
  • TError: queryFn에서 예상되는 오류 타입입니다. 이전 예제에서 Error입니다.
  • TData: 데이터 속성이 궁극적으로 갖게 될 입니다. 데이터 속성이 queryFn이 반환하는 것과 다를 수 있으므로 select 옵션을 사용하는 경우에만 관련이 있습니다. 그렇지 않으면 queryFn이 반환하는 것이 무엇이든 기본값이 됩니다.
  • TQueryKey: queryFn에 전달된 queryKey를 사용하는 경우에만 관련된 queryKey 타입입니다.

보시다시피 모든 Generics에는 기본값이 있습니다.

즉, 직접 값을 제공하지 않으면 TypeScript가 해당 타입으로 대체합니다.

이것은 JavaScript의 기본 매개변수와 거의 동일하게 동작합니다.

function multiply(a, b = 2) {
  return a * b
}

Type Inference

TypeScript는 스스로 어떤 타입이 되어야 하는지 추론(또는 파악)하도록 하면 가장 잘 작동합니다.
모든 타입을 입력할 필요가 없기 때문에 코드를 더 쉽게 작성할 수 있을 뿐만 아니라 읽기도 더 쉽게 만듭니다.
많은 경우에 코드를 JavaScript처럼 보이게 만들 수 있습니다.
타입 추론의 몇 가지 간단한 예는 다음과 같습니다.
const num = Math.random() + 5 // ✅ `number`

// 🚀 both greeting and the result of greet will be string
function greet(greeting = 'ciao') {
  return `${greeting}, ${getName()}`
}

Generics의 경우 일반적으로 사용 사례에서 유추할 수 있습니다. 이는 매우 훌륭합니다.

수동으로 제공할 수도 있지만 대부분의 경우에는 필요하지 않습니다.

function identity<T>(value: T): T {
  return value
}

// 🚨 no need to provide the generic
let result = identity<number>(23)

// ⚠️ or to annotate the result
let result: number = identity(23)

// 😎 infers correctly to `string`
let result = identity('react-query')

Partial Type Argument Inference (부분 타입 인수 추론)

해당 기능은 타입스크립트에 TypeScript에 존재하지 않습니다(open issue).
기본적으로 하나의 Generic을 제공하면 모두 제공해야 함을 의미합니다. placeholder에 위치가 지정되어있기 때문입니다.
 
React Query에는 Generics에 대한 기본값이 있지만 어떤 값이 사용되는지 즉시 알아차리지 못할 수 있습니다.
function useGroupCount() {
  return useQuery<Group[], Error>('groups', fetchGroups, {
    select: (groups) => groups.length,
    // 🚨 Type '(groups: Group[]) => number' is not assignable to type '(data: Group[]) => Group[]'.
    // Type 'number' is not assignable to type 'Group[]'.ts(2322)
  })
}​

세번째 위치 Generic을 제공하지 않았기 때문에 기본값인 Group[]이 지정되지만,

select 함수에서 number를 반환하여 타입 오류가 발생합니다.

한 가지 조치 방안은 3rd Generic을 추가하는 것입니다.

function useGroupCount() {
  // ✅ fixed it
  return useQuery<Group[], Error, number>('groups', fetchGroups, {
    select: (groups) => groups.length,
  })
}

Infer all the things (모든 것을 추론하자)

Generics를 전혀 전달하지 않고 TypeScript가 수행할 작업을 파악하도록 하여 시작하겠습니다.
먼저 queryFn에 좋은 리턴 타입이 있어야 합니다.
명시적 리턴 타입 없이 queryFn을 인라인하면 axios 또는 fetch가 제공하는 any 타입을 만나게 됩니다.
function useGroups() {
  // 🚨 data will be `any` here
  return useQuery('groups', () =>
    axios.get('groups').then((response) => response.data)
  )
}​

API 레이어를 쿼리와 분리된 상태로 유지하고 싶다면 암시적인 any를 피하기 위해 타입 정의를 추가해야 합니다.

그래야 React Query가 나머지를 추론할 수 있습니다.

function fetchGroups(): Promise<Group[]> {
  return axios.get('groups').then((response) => response.data)
}

// ✅ data will be `Group[] | undefined` here
function useGroups() {
  return useQuery('groups', fetchGroups)
}

// ✅ data will be `number | undefined` here
function useGroupCount() {
  return useQuery('groups', fetchGroups, {
    select: (groups) => groups.length,
  })
}
이 접근 방식의 장점은 다음과 같습니다.
  • 더 이상 수동으로 Generics를 지정하지 않아도 됩니다.
  • 세 번째(select) 및 네 번째(QueryKey) 제네릭이 필요한 경우에도 잘 작동합니다.
  • 더 많은 제네릭이 추가되어도 잘 작동할 것입니다.
  • 코드가 덜 혼란스럽고 js에 가깝습니다.

What about error?

기본적으로 Generics가 없으면 오류가 unknown으로 유추됩니다.
이것은 버그처럼 보입니다. 왜 Error가 아닌가요?
그러나 JavaScript에서는 무엇이든 throw 할 수 있기 때문에 의도된 것입니다.
에러가 Error 타입일 필요는 없습니다.
throw 5
throw undefined
throw Symbol('foo')
 
React Query는 Promise를 반환하는 함수를 담당하지 않기 때문에 어떤 타입의 오류가 발생할지 알 수 없습니다.
그래서 unknown이 맞습니다.
TypeScript가 여러 제네릭이 있는 함수를 호출할 때 일부 제네릭 건너뛰기를 허용하면(this issue for more information)
이 문제를 더 잘 처리할 수 있지만,
 
현재로서는 Error 작업이 필요하고
Generics 전달에 의존하고 싶지 않다면, instanceof 검사로 타입을 좁힐 수 있습니다.
const groups = useGroups()

if (groups.error) {
  // 🚨 this doesn't work because: Object is of type 'unknown'.ts(2571)
  return <div>An error occurred: {groups.error.message}</div>
}

// ✅ the instanceOf check narrows to type `Error`
if (groups.error instanceof Error) {
  return <div>An error occurred: {groups.error.message}</div>
}​
 
어쨌든 오류가 있는지 확인하기 위해 일종의 검사를 수행해야 하므로 instanceof 검사는 전혀 나쁜 생각처럼 보이지 않으며,
이는 런타임에 message property가 있는지 확인합니다.
 
이는 TypeScript가 4.4 릴리스를 위해 계획한 것과도 일치합니다.
여기서 새로운 컴파일러 플래그 useUnknownInCatchVariables를 도입할 예정입니다.(here)

타입 좁히기

React Query로 작업할 때 Destructuring을 거의 사용하지 않습니다.
우선, 데이터 및 오류와 같은 이름은 매우 보편적이므로(의도적으로 그렇게 했습니다) 어쨌든 이름을 바꾸게 될 것입니다.
 
전체 객체를 유지하면 데이터가 무엇인지 또는 오류가 발생한 위치에 대한 컨텍스트가 유지됩니다.
또한 상태 필드 또는 상태 부울 중 하나를 사용할 때 TypeScript가 타입을 좁히는 데 도움이 됩니다.
const { data, isSuccess } = useGroups()
if (isSuccess) {
  // 🚨 data will still be `Group[] | undefined` here
}

const groupsQuery = useGroups()
if (groupsQuery.isSuccess) {
  // ✅ groupsQuery.data will now be `Group[]`
}​
 
TypeScript 4.6에는 destructured discriminated unions에 대한 제어 흐름 분석이 추가되어 동일하게 타입이 추론됩니다만,
두번째 사용 방법을 고려해보시기 바랍니다.
control flow analysis for destructured discriminated unions

Type safety with the enabled option

종속 쿼리, 일부 매개변수가 아직 정의되지 않은 쿼리를 비활성화와 같은 기능을 제공하는 enabled 옵션은 막강합니다.
function fetchGroup(id: number): Promise<Group> {
  return axios.get(`group/${id}`).then((response) => response.data)
}

function useGroup(id: number | undefined) {
  return useQuery(['group', id], () => fetchGroup(id), {
    enabled: Boolean(id),
  })
  // 🚨 Argument of type 'number | undefined' is not assignable to parameter of type 'number'.
  //  Type 'undefined' is not assignable to type 'number'.ts(2345)
}
TypeScript는 맞습니다. id는 undefined일 수 있습니다.
enabled 옵션은 타입 축소를 수행하지 않습니다.
 
useQuery에서 반환된 refetch 메서드를 호출하여 enabled 옵션을 우회하는 방법이 있습니다.
이 경우도 id는 undefined일 수 있습니다.
non-null assertion operator가 마음에 들지 않으면,
id는 undefined일 수 있음을 받아들이고 queryFn에서 Promise를 reject하는 것이 가장 좋은 방법을 찾았습니다.
약간의 중복이 있지만 명시적이고 안전합니다.
function fetchGroup(id: number | undefined): Promise<Group> {
  // ✅ check id at runtime because it can be `undefined`
  return typeof id === 'undefined'
    ? Promise.reject(new Error('Invalid id'))
    : axios.get(`group/${id}`).then((response) => response.data)
}

function useGroup(id: number | undefined) {
  return useQuery(['group', id], () => fetchGroup(id), {
    enabled: Boolean(id),
  })
}

Optimistic Updates

낙관적 업데이트는 이제 추가 작업 없이 타입을 올바르게 추론합니다.

useInfiniteQuery

 
한 가지 눈에 띄는 문제는 queryFn에 전달되는 pageParam 값이 any로 타이핑 된다는 것입니다.
가능한 한 명시적으로 타입 주석을 추가하는 것이 가장 좋습니다.
type GroupResponse = { next?: number, groups: Group[] }
const queryInfo = useInfiniteQuery(
  'groups',
  // ⚠️ explicitly type pageParam to override `any`
  ({ pageParam = 0 }: { pageParam: GroupResponse['next']) => fetchGroups(groups, pageParam),
  {
    getNextPageParam: (lastGroup) => lastGroup.next,
  }
)​

fetchGroups가 GroupResponse를 반환하면 lastGroup은 해당 타입을 멋지게 추론하고,

동일한 타입을 사용하여 pageParam에 주석을 추가할 수 있습니다.

Typing the default query function

저는 개인적으로 defaultQueryFn을 사용하지 않지만 많은 사람들이 사용한다는 것을 알고 있습니다.
전달된 queryKey를 활용하여 요청 URL을 직접 작성하는 깔끔한 방법입니다.
queryClient를 생성할 때 함수를 인라인하면 전달된 QueryFunctionContext의 타입도 자동으로 유추됩니다.
TypeScript는 항목을 인라인할 때 훨씬 잘 동작합니다.
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      queryFn: async ({ queryKey: [url] }) => {
        const { data } = await axios.get(`${baseUrl}/${url}`)
        return data
      },
    },
  },
})​

 잘 동작하지만 전체 queryKey가 unknown[]이기 때문에 url은 unknown으로 유추됩니다.

queryClient를 생성할 때 useQuery를 호출할 때 queryKeys가 어떻게 구성되는지에 대한 보장이 전혀 없기 때문에
런타임 검사를 사용하여 타입을 좁힙니다. (나쁜 것이 아닙니다.)
예를 들면 다음과 같습니다.
const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      queryFn: async ({ queryKey: [url] }) => {
        // ✅ narrow the type of url to string so that we can work with it
        if (typeof url === 'string') {
          const { data } = await axios.get(`${baseUrl}/${url.toLowerCase()}`)
          return data
        }
        throw new Error('Invalid QueryKey')
      },
    },
  },
})

unknown이 any과 비교하여 왜 훌륭한지를 잘 보여준다고 생각합니다. (타입 검사 가능)

반응형

'FrontEnd' 카테고리의 다른 글

리액트 쿼리 : 쿼리키  (0) 2022.06.19
리액트 쿼리 : 웹소켓  (0) 2022.06.18
리액트 쿼리 : 테스트 / 테스팅  (0) 2022.06.18
리액트 쿼리 : 쿼리 상태 체크  (0) 2022.06.18
리액트 쿼리 : 렌더링 최적화  (0) 2022.06.17