TanStack Query (useQuery, useMutation)

Hunter Joe·2024년 12월 14일
0
post-thumbnail

들어가기 전..

TanStack Query 공식 홈페이지 안에 들어갔을 때 압도적인 문서량에 놀랐다.
그리고 문서의 정리 형식이 생각보다 복잡하게 되어있다고 느꼈다.

FE에서 가장 많이 쓰이는 CRUD를 기준으로 나눠서 글을 작성했다.
TanStack Query hook에는 많은 옵션들이 있지만 가장 많이 쓰이고 핵심 기능인
useQueryuseMutation부터 알아보자

C : useQueryGET 기능에 해당한다.
RUD : useMutationPOST/PATCH/DELETE 추가, 수정, 삭제 기능에 해당한다.

useQuery

  • 기본적으로 useQuery를 통한 쿼리 인스턴스는 캐시된 데이터를 Stale된 것으로 취급한다.

  • 이러한 취급을 바꾸고 싶다면 StaleTime 옵션을 따로 설정해서 해당 쿼리가
    Stale되지 않게 끔 설정이 가능하다.

  • ⭐다음의 경우에 stale Queries는 백그라운드에서 자동으로 리페칭한다.

    1. query의 새로운 인스턴스가 Mount 될 때 : refetchOnMount
    2. 브라우저 창이 다시 포커스 될 때 : refetchOnWindowFocus
    3. 네트워크가 재연결 됐을 때 : refetchOnReconnect
    4. 쿼리가 refetch interval로 설정됐을 때 : refetchInterval
  • 기본적으로 'inactive(비활성화)' 쿼리는 5분 후에 garbage로 수집된다.
    이를 변경하려면 gcTime을 설정해주면 된다. 기본값 : 1000 * 60 * 5

import { useQuery } from '@tanstack/react-query'

type ResponseValue = {
  message: string
  time: string
}

export default function DelayedData() {
  const { data } = useQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
    staleTime: 1000 * 10 // 10초
  })
  return <div>{data?.time}</div>
}

queryKey

  • TanStack Query는 querykey를 기반으로 쿼리 캐싱을 관리한다.
  • 중요한 점은 queryKey가 Serializable(직렬화) 기능을 하고, 쿼리의 데이터에 대해 unique하면 어떤 형태로든 사용할 수 있다는 것.
  • queryKey는 캐시된 데이터와 비교해 새로운 데이터를 가져올지, 캐시된 데이터를 사용할지 결정하는 기준을 정할 수 있다.

Serializable(직렬화)란??
직렬화 가능한 데이터
1. string, number, boolean, array, object
함수나, symbol 또는 순환 참조를 가진 데이터는 불가능!

// 가능한 것 
['user', { id: 123 }] // 배열과 객체 조합
['todos', 1, true]  
// 불가능한 것 
['user', new Map()]   // Map 객체는 JSON으로 직렬화 불가
['user', () => {}] 

queryFn

  • queryFn은 문자 그대로 Promise를 반환하는 모든 함수가 될 수 있다.(비동기 함수)
  • 꼭 데이터를 반환하거나 throw Error를 해야한다. → 에러의 기본 값은 null이다.
import { useQuery } from '@tanstack/react-query'

type ResponseValue = {
  message: string
  time: string
}

export default function DelayedData() {
  const { data, error } = useQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => {
      const res = await fetch('https://api.heropy.dev/v0/delay?t=1000')
      const data = await res.json()
      if (!data.time) {
        throw new Error('문제가 발생했습니다!')
      }
      return data
    },
    staleTime: 1000 * 10,
    retry: 1
  })
  return (
    <>
      {data && <div>{JSON.stringify(data)}</div>}
      {error && <div>{error.message}</div>}
    </>
  )
}

refetch

refetch함수를 사용하면 데이터를 항상 새롭게 다시 가져올 수 있다.

// ...

export default function DelayedData() {
  const { data, isStale, refetch } = useQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
    staleTime: 1000 * 10
  })
  return (
    <>
      <div>{data?.time}</div>
      <div>데이터가 상했나요?: {JSON.stringify(isStale)}</div>
      <button onClick={() => refetch()}>데이터 가져오기!</button>
    </>
  )
}
  • 만약 staleTime을 기반으로 데이터를 가져오려면, queryClient.fetchQuery()메서드를 사용할 수 있다.

⚠️주의

queryKeystaleTime을 기존 쿼리와 동일하게 제공해야 한다.(queryFn은 생략 가능)

queryOptions 함수를 사용해 옵션을 미리 정의하고 재사용할 수 있다.

import { useQuery, useQueryClient, queryOptions } from '@tanstack/react-query'
// ...

const options = queryOptions<ResponseValue>({
  queryKey: ['delay'],
  queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
  staleTime: 1000 * 10
})

export default function DelayedData() {
  const queryClient = useQueryClient()
  const { data, isStale } = useQuery(options)
  
  function fetchData() {
    const data = await queryClient.fetchQuery(options)
    console.log(data) // 캐시된 데이터 or 새로 가져온 데이터
  }
  return (
    <>
      <div>{data?.time}</div>
      <div>데이터가 상했나요?: {JSON.stringify(isStale)}</div>
      <button onClick={fetchData}>데이터 가져오기!</button>
    </>
  )
}

만약 캐시된 데이터가 필요하다면, queryClient.getQueryData() 메소드를 사용할 수 있다.
데이터가 상해도 새로 가져오지 않고, 캐시된 데이터만 반환
캐시된 데이터가 없는 경우, undefined를 반환.

queryClient.getQueryData() 대신 queryClient.ensureQueryData() 메소드를 사용하면, 캐시된 데이터가 없는 경우 undefined를 반환하지 않고, 자동으로 queryClient.fetchQuery() 메소드를 호출해 데이터를 가져온다.

useQueryClient 훅을 사용해 queryClient 객체를 가져온 후, getQueryData 메소드를 사용

import { useQuery, useQueryClient } from '@tanstack/react-query'
// ...

export default function DelayedData() {
  const queryClient = useQueryClient()
  const { data, isStale } = useQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => (await fetch('https://api.heropy.dev/v0/delay?t=1000')).json(),
    staleTime: 1000 * 10
  })
  
  function getCachedData() {
    const data = queryClient.getQueryData(['delay'])
    console.log(data) // 캐시된 데이터 or undefined
  }
  return (
    <>
      <div>{data?.time}</div>
      <div>데이터가 상했나요?: {JSON.stringify(isStale)}</div>
      <button onClick={getCachedData}>데이터 가져오기!</button>
    </>
  )
}

Render Optimizations

구조적 공유 (structural sharing)

  • TanStack Query는 "structural sharing"(구조적 공유)라는 기술을 사용해 리렌더링 간에 가능한 많은 참조를 유지한다
  • TanStack Query는 데이터에 변경 사항이 없으면 기존 참조를 유지한다.
  • 데이터의 일부만 변경된 경우, 변경되지 않은 부분은 유지하고 변경된 부분만 대체한다.

참고

  • 이러한 최적화는 queryFn이 JSON 호환 데이터를 반환하는 경우에만 작동
  • structuralSharing: false로 설정하면 전역적으로 또는 개별 쿼리에서 이 기능을 비활성화할 수 있다.
  • structuralSharing통해 메모리 사용량을 최적화하고 불필요한 리렌더링을 방지할 수 있지만
    때로는 structuralSharing 옵션을 false로 지정하는게 더 유리할 수 있다.
    WHY?
    매우 큰 중첩 객체를 다루는 경우 구조적인 비교 자체가 성능에 부담이 될 수 있다.
    또한 데이터가 항상 새로운 참조여야 하거나 데이터가 단순해 깊은 비교가 필요하지 않는 경우에도 false로 지정하는 것이 좋다.

select

select함수를 사용하면 가져온 데이터를 변형(선택)할 수 있다.
쿼리 함수가 반환하는 데이터를 인수로 받아 선택함수에서 처리하고 반환하면 최종 데이터가 된다.
최종 데이터 타입은 useQuery의 3번째 제네릭 타입으로 선언할 수 있다.

import { useQuery } from '@tanstack/react-query'

type Users = User[]
interface User {
  id: string
  name: string
  age: number
}

export default function UserNames() {
  const { data } = useQuery<Users, Error, string[]>({
    queryKey: ['users'],
    queryFn: async () => {
      const res = await fetch('https://api.heropy.dev/v0/users')
      const { users } = await res.json()
      return users
    },
    staleTime: 1000 * 10,
    select: data => data.map(user => user.name)
  })
  return (
    <>
      <h2>User Names</h2>
      <ul>{data?.map((name, i) => <li key={i}>{name}</li>)}</ul>
    </>
  )
}

쿼리 함수를 따로 선언해 제공하면, 선택 함수를 통한 최종 데이터의 타입을 추론할 수 있다.
다음 예제에서 쿼리 함수의 반환은 Users 타입이고, 최종 데이터(선택 함수의 반환)은 string[] 타입으로 추론

// ...

async function queryFn(): Promise<Users> {
  const res = await fetch('https://api.heropy.dev/v0/users')
  const { users } = await res.json()
  return users
}
export default function UserNames() {
  // data는 string[] 타입으로 추론
  const { data } = useQuery({
    queryKey: ['users'],
    queryFn,
    staleTime: 1000 * 10,
    select: data => data.map(user => user.name)
  })
  // ...
}

useMutation

useMutation훅을 통해 데이터 변경 작업을 처리하고 다양한 상태를 얻을 수 있다(성공, 실패, 로딩)
useQuery와 비슷하지만 queryKey가 안들어 감

const { data, isLoading, mutate, mutateAsync } = useMutation(mutationFn, options);

mutationFn : 비동기 함수
useMutation에서 가장 많이 쓰이는 2가지 옵션

  • optimistic update
  • invalidateQueries

Optimistic Update

서버 요청의 응답을 기다리지 않고, 먼저 UI를 업데이트하는 기능
서버 응답이 느린 상황에서도 빠른 인터페이스를 제공할 수 있어 사용자 경험을 크게 향상

invalidateQueries

useQuery에서 사용되는 queryKey의 캐시 데이터를 제거하고
캐시가 없기 때문에 staleTime,cacheTime을 무시하고 무조건 새로운 데이터를 가져올 수 있게 됨

  • 사용자 목록을 가져오는 쿼리
import { useQuery } from '@tanstack/react-query'

export type Users = User[]
export interface User {
  name: string
  age: number
  isValid?: boolean
  emails?: string[]
  photo?: {
    name: string
    data: string // Base64
  }
  id?: string
}

export default function Users() {
  const { data } = useQuery<Users>({
    queryKey: ['users'],
    queryFn: async () => {
      const res = await fetch('https://api.heropy.dev/v0/users') // GET
      const json = await res.json()
      return json.users
    },
    staleTime: 1000 * 60 * 5 // 5분
  })

  // ...
}

다음 예제는 입력한 사용자의 이름과 나이로 새로운 사용자를 추가하는 예제

import React, { useState } from 'react'
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { Users, User } from './Users'

export default function AddUser() {
  const [name, setName] = useState('')
  const [age, setAge] = useState(0)
  const queryClient = useQueryClient()

  const { mutate, error, isPending, isError } = useMutation({
    mutationFn: async (newUser: User) => { // 
      const res = await fetch('https://api.heropy.dev/v0/users', {
        method: 'POST',
        body: JSON.stringify(newUser)
      })
      if (!res.ok) throw new Error('변이 중 에러 발생!') // 변이 실패!
      return res.json() // 변이 성공!
    },
    onMutate: async newUser => {
      // 낙관적 업데이트 전에 사용자 목록 쿼리를 취소해 잠재적인 충돌 방지!
      await queryClient.cancelQueries({ queryKey: ['users'] })

      // 캐시된 데이터(사용자 목록) 가져오기!
      const previousUsers = queryClient.getQueryData<Users>(['users'])

      // 낙관적 업데이트
      if (previousUsers) {
        queryClient.setQueryData<Users>(['users'], [...previousUsers, newUser])
      }

      // 각 콜백의 context로 전달할 데이터 반환!
      return { previousUsers }
    },
    onSuccess: (data, newUser, context) => {
      console.log('onSuccess', data, newUser, context)
      // 변이 성공 시 캐시 무효화로 사용자 목록 데이터 갱신!
      queryClient.invalidateQueries({ queryKey: ['users'] })
    },
    onError: (error, newUser, context) => {
      console.log('onError', error, newUser, context)
      // 변이 실패 시, 낙관적 업데이트 결과를 이전 사용자 목록으로 되돌리기!
      if (context) {
        queryClient.setQueryData(['users'], context.previousUsers)
      }
    },
    onSettled: (data, error, newUser, context) => {
      console.log('onSettled', data, error, newUser, context)
    },
    retry: 3, // 변이 실패 시 3번 재시도
    retryDelay: 500 // 0.5초 간격으로 재시도
  })

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    mutate({ name, age }) // 변이!
  }

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={name}
        onChange={e => setName(e.target.value)}
        placeholder="사용자 이름"
      />
      <input
        type="number"
        value={age || ''}
        onChange={e => setAge(Number.parseInt(e.target.value, 10))}
        placeholder="사용자 나이"
      />
      <button
        type="submit"
        disabled={isPending}>
        {isPending ? '사용자 추가 중..' : '사용자 추가하기!'}
      </button>
      {isError && <p>에러 발생: {error.message}</p>}
    </form>
  )
}

mutate

  • useMutation의 속성 중 하나로
  • mutateuseMutation을 조작할 수 있는 속성
  • mutate안에는 useMutation의 비동기 함수에 들어갈 인자가 들어간다.
  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault()
    mutate({ name, age }) // 변이!
  }

참고자료

profile
Async FE 취업 준비중.. Await .. (취업완료 대기중) ..

0개의 댓글