Tanstack query V5 총정리

이수빈·2024년 7월 9일
3

Next.js

목록 보기
15/15
post-thumbnail

useQuery

const 반환 = useQuery<데이터타입>(옵션)

useQuery Option

enabled

  • enabled: boolean | (query: Query) => boolean

  • Set this to false to disable this query from automatically running.

  • Dependent Query로 사용 될 수 있다. => userId값이 있으면 쿼리를 가져오고, 아니면 쿼리가 실행되지 않는다.

const { data: user } = useQuery({
  queryKey: ['user', email],
  queryFn: getUserByEmail,
})

const userId = user?.id

// Then get the user's projects
const {
  status,
  fetchStatus,
  data: projects,
} = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,
  // The query will not execute until the userId exists
  enabled: !!userId,
})

select

  • 쿼리에서 가져온 데이터를 가공해서 사용 가능하다.

  • queryFn에서도 가공할 수 있지만, api 타입이 맞지 않는경우 존재 가능 => 3번째 제너릭으로 select의 type을 선언 가능하다.

  • select에서 사용해야 memoization이 된다.

  • (즉 데이터가 갱신되는 상황에서 데이터가 동일한 상황일때, queryFn 에서는 가공로직을 돌지만, select로 처리하게 되면 가공로직을 돌지 않는다.)

 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)
  })

initialData: TData | () => TData

  • queryCache의 initial Data 로 사용된다. (쿼리가 생성되거나 캐시되지 않은 경우에만()

refetchInterval

  • 데이터 자동 갱신(다시 가져오기)의 시간 간격(ms), queryInstance가 다시 mount된 상태가 아니라도 서버로 데이터를 호출해 가져온다.

  • stale여부와 상관없이 시간에 따라 가져온다.

  • 함수형태로 작성해 특정조건에서 interval을 지정가능하다.

refetchIntervalInBackground

  • background에서 다시 페이지로 돌아왔을때 => 데이터를 갱신함.

  • stale 여부와 상관없이 시간에 따라 가져온다.

refetchOnMount

  • useQuery 연결 시 데이터 갱신 여부.

  • true: 연결 시 데이터가 상한 경우만 갱신.

  • always: 연결 시 데이터 항상 갱신.

refetchOnReconnect

  • 네트워크 재 연결시 데이터 갱신 여부 => 서버로부터 다시 가져온다.

  • true 이면 만약 쿼리가 stale상태라면, 네트워크가 재연결되었을 때 다시 가져온다.

refetchOnWindowFocus

  • 브라우저 화면 포커스시 데이터 갱신 여부

  • true 이면 만약 쿼리가 stale상태라면, window가 refetch 되었을때 다시 가져온다.

useQuery Return

isFetching

  • 데이터를 가져오는 중을 나타냄 (쿼리가 처음에 가져오든, 수동으로 가져오든 데이터를 가져오는 중간에 바뀌는 값)

  • isLoading은 isFetching && isPending과 동일, 첫번째 가져오기가 진행중임을 나타내는 값임.

Refetch vs QueryClient.getQueryData

  • refetch : 강제로 쿼리를 가져오게함. => 동일한 queryKey로 stale여부와 상관없이 데이터를 서버로부터 갱신한다.

  • QueryClient.getQueryData : QueryCache에서 무조건 cache된 데이터를 가져온다.

  • queryClient.fetchQuery() : 두 함수의 절충안, 데이터가 상했다면 서버에서 재호출하고, 상하지 않았다면 queryClient의 queryCache로부터 데이터를 가져온다.

  • getQueryData와 다르게 queryOptions을 같이 넘겨줘야한다.(만약 stale인 상태라면 => 서버에서 가져와 다시 캐싱을 하기 때문)

  • 그렇기 때문에, queryKey가 동일한 useQuery와 fetchQuery의 Option을 동기하화는게 필요하다.(staleTime을 동일하게!)

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

  const queryData = useCallback(async () => {
    const data = await queryClient.fetchQuery(options)
    console.log(data) // 캐시된 데이터 or 새로 가져온 데이터
  }, [queryClient])

Query Key

  • queryKey는 배열이여야 한다, 쿼리를 식별하는 고유한 값.

  • 다중아이템 쿼리키를 사용할 때는, 아이템의 순서가 중요하다.

  • 기본적으로 queryFn에서 사용하는 변수는 쿼리키에 포함되어야 한다 => 쿼리키가 변하면 서버에서 데이터를 갱신 할 수 있기 때문

  • 내부에서는 queryKey가 직렬화되어 관리되기 때문에 => 아래 서로같은 쿼리에서 undefined부분은 제거되어 같게 취급된다.

  • 하지만, queryKey는 array이기 때문에 queryKey의 순서가 중요하다.

// 단일 아이템 쿼리 키
useQuery({ queryKey: ['hello'] })

// 다중 아이템 쿼리 키
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] })

// 서로 같은 쿼리
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] })
useQuery({ queryKey: ['hello', 'world', 123, { b: 2, c: undefined, a: 1 }] })

// 서로 다른 쿼리
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2 }] })
useQuery({ queryKey: ['hello', 'world', 123, { a: 1, b: 2, c: 3 }] })
useQuery({ queryKey: ['hello', 'world'] })
useQuery({ queryKey: [123, 'world', { a: 1, b: 2, c: 3 }], 'hello' })

Query Key의 Hashing?

  • useQuery를 통해 데이터를 가져오면 => queryKey값을 기반으로 QueryClient에 저장

  • staleTime은 리엑트 쿼리애개 캐시된 데이터를 얼마나 자주 최신화 시켜줘야 하는지 알려줌.

  • cacheTime이 지났다면 GC에 의해 Query Manager에서 사라진다.

  • useQuery, useInfiniteQuery로 만들어져 사용된 인스턴스들은 다른 컴포넌트에서 사용될 때를 대비해 캐시되며, 5분 후에 Garbage Collection (이하 GC)에 의해 사라진다. 기본값은 5분, 5버전부터는 gcTime으로 변경됨.

  • refetching이 일어나는 특정조건은 다음과 같다. => query가 stale상태로 변했다고 바로 서버에서 데이터를 갱신하는게 아니다!!

새로운 Query Instance가 마운트 될 때 (페이지 이동 후 등의 상황)
브라우저 화면을 다시 focus 할 때
인터넷이 재연결되었을 때
refetchInterval이 설정되어있을 때

removeQueries, invalidateQueries

  • 아래와 같은 queryKey구조가 있다.
{
  ['todos', 'list', { filters: 'all' }],
  ['todos', 'list', { filters: 'done' }],
  ['todos', 'detail', 1],
  ['todos', 'detail', 2],
}
  • 아래 코드를 실행하면 => ['todo', 'list'] 에 해당하는 모든 하위 쿼리들이 무효화된다.

  • removeQueries도 마찬가지. 만약 정확하게 동일한 key를 가진 query만 무효화 하고 싶다면 exact 옵션을 사용 할 수 있다.

queryClient.invalidateQueries(['todos', 'list'])
queryClient.removeQueries({ queryKey, exact: true })

https://tanstack.com/query/latest/docs/reference/QueryClient/#queryclientinvalidatequeries

await queryClient.invalidateQueries(
  {
    queryKey: ['posts'],
    exact,
    refetchType: 'active',
  },
  { throwOnError, cancelRefetch },
)
  • invalidateQueries은 3번째 옵션으로 refetchType이 존재한다. 기본값은 active인데, queryKey와 matching 되는 active한 쿼리를 무효화하고 다시 가져온다.

  • queryKey와 일치해도 상태가 active가 아니라면, 무효화시키지 않는다. 이때는 refetchType을 따로 설정해줘야한다.

useInfiniteQuery

  • 무한스크롤 구현시 사용하는 쿼리. 무한스크롤을 구현하기 쉽게 도와준다.
const 반환 = useInfiniteQuery<페이지타입>(옵션)
const {
    data, // 가져온 데이터
    isLoading, // 첫 페이지 가져오는 중
    isFetching, // 다음 페이지 가져오는 중
    isFetched, // 첫 페이지 가져오기 완료
    hasNextPage, // 다음 페이지가 있는지 여부
    fetchPreviousPage, // 이전 페이지 가져오기 함수
    fetchNextPage // 다음 페이지 가져오기 함수
  } = useInfiniteQuery<Page>({
    queryKey: ['movies', queryText], // 검색어로 쿼리 키 생성!
    queryFn: async ({ pageParam }) => {
      const res = await fetch(
        `https://omdbapi.com/?apikey=7035c60c&s=${queryText}&page=${pageParam}`
      )
      return res.json()
    },
    initialPageParam: 1, // 첫 페이지 번호 초기화!
    //처음 pageParams로 들어간다.
    getNextPageParam: (lastPage, pages) => {
      // 한 페이지당 최대 10개까지의 영화 정보를 가져옴!
      // 마지막 페이지 번호 계산!
      const maxPage = Math.ceil(Number.parseInt(pages[0].totalResults, 10) / 10)
      // maxPage를 계산함

      // 다음 페이지가 있으면, 다음 페이지 번호 반환! => 계속 호출가능
      // hasNextPage값이 true
      if (lastPage.Response === 'True' && pages.length < maxPage) {
        return pages.length + 1
      }
      // 다음 페이지가 없으면 undefined | null 반환! => 호출 종료
      // hasNextPage값이 false
      return undefined
    },
    //lastPage => 페이지의 데이터
    enabled: false, // 검색어 입력 전까지 대기!
    staleTime: 1000 * 60 * 5 // 5분
  })


  useEffect(() => {
    
    if (queryText) fetchPreviousPage()
  }, [queryText, fetchPreviousPage])
// 검색어가 변경될 때마다, 캐시된 데이터가 있어서 
//그 데이터의 다음 페이지를 가져오지 않도록 이미 캐시된 이전 페이지를 가져옴!
/// 만약 query가 fresh라면, 캐시된 데이터가 있는 경우 => 이전에 가져왔던 데이터(이전페이지)를 가져온다.

...

 {isFetched && hasNextPage && (
        <button
          disabled={isFetching}
          onClick={() => fetchNextPage()}>
          {isFetching ? '로딩 중..' : '더 보기!'}
        </button>
     )}
     
     //아래에서 버튼을 누르면 => 다음 페이지를 가져온다.

useInfiniteQuery Options

  • 기본적으로 useQuery의 모든 옵션을 가질 수 있다. 추가적인 옵션들은 다음과 같다.

getNextPageParam

  • 새로운 다음 페이지를 가져오면, 다음 페이지의 정보로 호출되는 함수.(필수 옵션!)

  • 다음 페이지 번호를 반환해야 함, 다음 페이지가 없으면, undefined 또는 null을 반환해야 함!

(lastPage: TPage, allPages: TPage[], lastPageParam: number, allPageParams: number[]) => TPageParam | undefined | null

getPreviousPageParam

  • 새로운 이전 페이지를 가져오면, 이전 페이지의 정보로 호출되는 함수.

  • 이전 페이지 번호를 반환해야 함! 이전 페이지가 없으면, undefined 또는 null을 반환해야 함!

(firstPage: TPage, allPages: TPage[], firstPageParam: number, allPageParams: number[]) => TPageParam | undefined | null

initialPageParam

  • 첫번째 페이지의 번호, 필수옵션임.

maxPages

  • 저장 및 출력할 최대 페이지의 수. 페이지가 지나치게 많은 경우에 유용. => 만약 현재 페이지수가 maxPage보다 많아지면, 지우는 로직을 작성 할 수 있다.

useInfiniteQuery Return

fetchNextPage

  • 다음페이지를 가져오는 함수, 특정시점에 데이터를 가져오도록 호출해줘야한다.
(options?: FetchNextPageOptions) => Promise<UseInfiniteQueryResult>

fetchPreviousPage

  • 이전 페이지를 가져오는 함수, 특정시점에 데이터를 가져오도록 호출해줘야한다.
(options?: FetchPreviousPageOptions) => Promise<UseInfiniteQueryResult>

hasNextPage, hasPreviousPage

  • hasNextPage : 다음페이지가 있는지의 여부

  • hasPreviousPage : 이전페이지가 있는지의 여부

isFetchingNextPage, isFetchingPreviousPage

  • isFetchingNextPage : 다음 페이지를 가져오는 중인지의 여부.

  • isFetchingPreviousPage : 이전 페이지를 가져오는 중인지의 여부.

무한스크롤 구현

  • js의 Intersection observer를 이용해 구현한다.

Intersection Obeserver api와 함께 사용하기

  • 웹 페이지에서 DOM 요소의 가시성 및 위치를 감시하고, 요소가 화면에 들어오거나 나갈 때 이벤트를 트리거하는 JavaScript API

  • Scroll event는 동기적으로 실행되기 때문에 메인스레드에 영향을 줌. 또한 여러 scroll event가 등록되어있을경우 이벤트가 중첩되어 호출되는 현상이 발생가능

  • Intersection Observer api는 비동기적으로 실행되기 때문에 메인스레드에 영향을 주지 않으면서 변경사항 관찰 가능함.

  • 무한스크롤이나, 페이지 스크롤시 이미지를 Lazy Loading 하는데 사용가능함.

사용법

  • Intersection Observer 객체 생성

  • IntersectionObserverCallback: IntersectionObserverCallback은 관찰된 요소의 상태가 변경될 때 호출되는 콜백 함수입니다. 이 콜백 함수는 두 개의 매개변수를 받습니다

  • entries 및 observer.

  • entries는 IntersectionObserverEntry 객체의 리스트임.

  • observer는 생성한 인스턴스를 참조함.

const observer = new IntersectionObserver(callback, options);

function callback(entries, observer) {
  entries.forEach((entry) => {
    // entry.intersectionRatio를 통해 가시성 정보 확인
    if (entry.isIntersecting) {
      // 요소가 화면에 들어옴
    } else {
      // 요소가 화면을 벗어남
    }
  });
}
  • isIntersecting : 관찰대상의 교차대상에 대한 정보 viewport와 교차상태로 들어가면 => true가됨. 교차하지 않으면 false가 됨.

options

  • root : 교차영역의 기준이 될 root 엘리먼트. observe의 대상으로 등록할 엘리먼트는 반드시 root의 하위요소여야만함. (default : null, 브라우저 viewport)

  • rootMargin : default : '0px 0px 0px 0px' => root element마진값.

  • threshold : 0 ~ 1 사이의 숫자혹은 이 숫자들의 배열. 타깃 엘리먼트에 대한 교차비율을 의미함. 0이라면, 타깃 엘리먼트가 교차영역에 진입했을때 observer를 실행, 1이라면 타깃 전체가 들어왔을 때 observer를 실행

Method

  • observe : 타겟에 대한 관찰을 시작할 때 사용함

  • unobserve : 타깃에 대한 관찰을 멈출 때 사용함.

  • disconnect : 전체 다수 엘리먼트에 대한 관찰을 멈출때 사용

  • takeRecords : IntersectionObserverEntry 배열반환.

활용방법

  • 콜백함수에서 isIntersecting을 감지 후, fetchNextPage 함수를 통해 직접 api를 호출하는 방식으로 무한스크롤을 구현 가능하다.

  • 스크롤이 하단까지 안닿는 경우 => 버튼을 넣는것도 해결방법

export default function MovieList() {
...
  const observerEl = useRef<HTMLDivElement | null>(null)
  
  useEffect(() => {
    const currentObserverEl = observerEl.current
    const io = new IntersectionObserver(entries => {
      if (entries[0].isIntersecting && hasNextPage) {
        fetchNextPage()
      }
    })
    if (currentObserverEl) {
      io.observe(currentObserverEl)
    }
    return () => {
      if (currentObserverEl) {
        io.disconnect()
      }
    }
  }, [hasNextPage, fetchNextPage])
  
  return (
  ...
   {isFetching ? <div>로딩 중..</div> : null}
      <div
        ref={observerEl}
        style={{ height: '20px' }}
      />
  )
  • react-intersection-observer 라이브러리를 사용할 수 있다.(로직간소화가능)
import { useInView } from 'react-intersection-observer'

export default function MovieList() {
...
  const { ref, inView } = useInView()
  
  ...
  
   useEffect(() => {
    if (inView && hasNextPage) {
      fetchNextPage()
    }
  }, [inView, hasNextPage, fetchNextPage])
  
  return (
  ...
     <div
        ref={ref}
        style={{ height: '20px' }}
      />
  )

useMutation

  • post,put,delete시 사용

  • Optimistic Update 사용가능 (mutation 시 먼저 UI를 update하는 기능)

  • 만약 잘못된 응답이 왔다면 => 원래 UI로 돌려야함.

const 반환 = useMutation(옵션)

useMutation Options

mutationFn

  • 비동기 변이함수 => 필수조건임

onMutate

  • 변이함수가 실행되기 전에 호출되는 함수

onSettled

  • 변이가 성공하거나 실패해도 항상 호출되는 함수.

onSuccess, onError

  • onSuccess : 성공시 실행되는 함수

  • onError : 실패시 실행되는 함수

retry, retryDelay

  • retry : 재시도 횟수, 기본값 0

  • retryDelay : 재시도 시작 간격(2배씩 증가해 시도한다. 1초 =>2초 => 4초...)

scope

  • mutation은 기본적으로 병렬실행인데, scope를 통해 같은 범위 ID를 가진 변이는 병렬이 아닌 직렬로 실행가능.

mutationKey

  • queryClient.setMutationDefaults의 기본값 상속을 위한 키

  • 캐시할때 사용함, 캐시한 데이터를 일치시킬 때 (근데 잘 안씀)

useMutation Return

isPending

  • mutation이 실행중인지에 대한 여부

mutate mutateAsync

  • mutate는 void반환 => scope를 통해 여러 컴포넌트에서 병렬처리를 보장 할 수 있다.

  • mutateAsync는 promise반환 => 하나의 컴포넌트 안에서 await으로 처리 순서를 보장 할 수 있다.

mutate + optimistic update

  • onMutate => 변이함수가 실행되기 전에 호출되는 함수이다. 여기서 먼저 front local data를 가지고 낙관적 업데이트를 진행한다.

  • onSuccess => mutation이 성공했다면? => 쿼리무효화를 통해 서버로부터 데이터 갱신

  • onError => 실패했다면 다시 캐시데이터를 원상복구, context로 데이터를 전달한다!

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 cachedUsers = queryClient.getQueryData<Users>(['users'])

      // 낙관적 업데이트
      if (cachedUsers) {
        queryClient.setQueryData<Users>(['users'], [...cachedUsers, newUser])
      } //현재 캐시데이터 업데이트 

      // 각 콜백의 context로 전달할 데이터 반환!
      return { previousUsers : cachedUsers }
    }, // 사전에 동작
    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>
  )
}

useSuspenseQuery

  • useSuspenseQuery를 사용하면, 서버 측 렌더링 단계에서 가져오기를 시도합니다.

  • 원래라면 => query로 가져온 후 => client측에서 렌더링을 업데이트 했으나 요청을 날림과 동시에 서버에서 렌더링해서 보내주는 형태임.

'use client'
import { useSuspenseQuery } from '@tanstack/react-query'

type ResponseValue = {
  message: string
  time: string
}

export default function DelayedData() {
  const { data } = useSuspenseQuery<ResponseValue>({
    queryKey: ['delay'],
    queryFn: async () => {
      const res = await fetch('https://api.heropy.dev/v0/delay?t=1000', {
        cache: 'no-store'
      })
      return res.json()
    },
    staleTime: 1000 * 10
  })
  return <div>{data.time}</div>
}
  • Suspense 컴포넌트로 래핑해서 사용해야 한다. (fallback)
import { Suspense } from 'react'
import DelayedData from '@/components/DelayedData'

export default function Page() {
  return (
    <Suspense fallback={<div>loading..</div>}>
      <DelayedData />
    </Suspense>
  )
}

ref) https://www.heropy.dev/p/HZaKIE
https://tanstack.com/query/latest
Intersection Observer api : https://blog.hyeyoonjung.com/2019/01/09/intersectionobserver-tutorial/
mdn : https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API

profile
응애 나 애기 개발자

0개의 댓글