Tanstack Query - 4. Advanced Query

miinhho·2025년 6월 16일

Tanstack-Query

목록 보기
4/4
post-thumbnail

아래 글은 tanstack query 공식 문서(5.80.7)를 기반으로 쓰여졌습니다. 대부분의 예제와 설명은 tanstack query 문서에서 가져왔습니다.


Query Retry

useQuery 가 실패하면, Tanstack Query 는 자동으로 설정된 retry 횟수만큼 재시도한다.

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

// Make a specific query retry a certain number of times
const result = useQuery({
  queryKey: ['todos', 1],
  queryFn: fetchTodoListPage,
  retry: 10, // Will retry failed requests 10 times before displaying an error
})
  • retry = false 는 retry 를 비활성화한다.
  • retry = 6 은 6번 재시도한다.
  • retry = true 는 성공할 때까지 계속 재시도한다.
  • retry = (failureCount, error) => ... 과 같이 커스텀 로직을 구현할 수 있다.

"서버의 경우 렌더링 속도를 올리기 위해 기본적으로 retry 가 0 으로 설정되어 있다."


error 프로퍼티의 내용은 마지막 재시도까지 failureReason 에 포함된다. 모든 재시도 이후에도 에러가 지속된다면 error 에 저장된다.

Retry Delay

기본적으로 Tanstack Query 는 요청이 실패되었을 때 바로 다음 요청을 보내지않고, 백 오프를 적용한다.

retryDelay1000 ms 부터 시작해 2배로 증가하여 30초를 넘기지 않는 것이 기본값이다.

// Configure for all queries
import {
  QueryCache,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retryDelay: (attemptIndex) => Math.min(1000 * 2 ** attemptIndex, 30000),
    },
  },
})

function App() {
  return <QueryClientProvider client={queryClient}>...</QueryClientProvider>
}

추천되지는 않지만, 아래와 같이 바로 요청을 보낼 수 있다.

const result = useQuery({
  queryKey: ['todos'],
  queryFn: fetchTodoList,
  retryDelay: 1000, // Will always wait 1000ms to retry, regardless of how many retries
})

Paginated / Lagged Query

쿼리 키에 페이지 정보를 담으면 Tanstack Query 에서 자동으로 페이징된 데이터를 받아올 수 있다.

const result = useQuery({
  queryKey: ['projects', page],
  queryFn: fetchProjects,
})

다만 위와 같은 코드는 각 페이지를 새로운 쿼리로 취급하기 때문에 successpending 상태를 오가며 UI 가 전환된다.


placeholderData

placeholderData(previousData) => previousData 로 설정하거나 keepPreviousData 함수를 사용해서 다음과 같은 이점을 얻을 수 있다.

  • 쿼리 키가 변경되어 새로운 데이터가 요청되는 동안, 마지막에 fetch 된 데이터를 사용할 수 있다.
  • 새로운 데이터가 도착하면, data 변수가 새로운 데이터로 변경된다.
  • 마지막에 fetch 된 데이터가 data 로 현재 대체되어 제공되는지 isPlaceholderData 로 제공된다.
import { keepPreviousData, useQuery } from '@tanstack/react-query'

function Todos() {
  const [page, setPage] = React.useState(0)

  const fetchProjects = (page = 0) =>
    fetch('/api/projects?page=' + page).then((res) => res.json())

  const { isPending, isError, error, data, isFetching, isPlaceholderData } =
    useQuery({
      queryKey: ['projects', page],
      queryFn: () => fetchProjects(page),
      placeholderData: keepPreviousData,
    })

  return (
    <div>
      {isPending ? (
        <div>Loading...</div>
      ) : isError ? (
        <div>Error: {error.message}</div>
      ) : (
        <div>
          {data.projects.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </div>
      )}
      <span>Current Page: {page + 1}</span>
      <button
        onClick={() => setPage((old) => Math.max(old - 1, 0))}
        disabled={page === 0}
      >
        Previous Page
      </button>
      <button
        onClick={() => {
          if (!isPlaceholderData && data.hasMore) {
            setPage((old) => old + 1)
          }
        }}
        // Disable the Next Page button until we know a next page is available
        disabled={isPlaceholderData || !data?.hasMore}
      >
        Next Page
      </button>
      {isFetching ? <span> Loading...</span> : null}
    </div>
  )
}

Infinite Query

useInfiniteQuery 를 통해 무한 스크롤과 같은 기능을 쉽게 구현할 수 있다.

useInfiniteQuery 에서는 다음과 같은 점들이 달라진다.

  • data 가 무한한 쿼리 데이터를 포함하는 object 가 된다.
  • data.pages 배열이 fetch 된 페이지를 포함한다.
  • data.pageParams 배열이 페이지를 fetch 하기 위해 사용된 페이지 매개변수를 포함한다.
  • fetchNextPage, fetchPreviousPage 가 활성화된다. (fetchNestPage 는 필수)
  • initialPageParam 옵션이 활성화되고, 초기 페이지 매개변수를 명시한다. (필수)
  • getNextPageParamgetPreviousPageParam 옵션들은 추가적으로 로딩할 데이터가 있는지와 해당 데이터를 fetch 할 정보를 확인하는 데 사용할 수 있다.
  • hasNextPage 가 활성화되고 getNextPageParamnull 이나 undefined 가 아닌 정보를 반환한다면 true 이다.
  • isFetchingNextPageisFetchingPreviousPage 를 통해 백그라운드 새로고침 상태와 추가 로딩 상태를 구별할 수 있다.
import { useInfiniteQuery } from '@tanstack/react-query'

function Projects() {
  const fetchProjects = async ({ pageParam }) => {
    const res = await fetch('/api/projects?cursor=' + pageParam)
    return res.json()
  }

  const {
    data,
    error,
    fetchNextPage,
    hasNextPage,
    isFetching,
    isFetchingNextPage,
    status,
  } = useInfiniteQuery({
    queryKey: ['projects'],
    queryFn: fetchProjects,
    initialPageParam: 0,
    getNextPageParam: (lastPage, pages) => lastPage.nextCursor,
  })

  return status === 'pending' ? (
    <p>Loading...</p>
  ) : status === 'error' ? (
    <p>Error: {error.message}</p>
  ) : (
    <>
      {data.pages.map((group, i) => (
        <React.Fragment key={i}>
          {group.data.map((project) => (
            <p key={project.id}>{project.name}</p>
          ))}
        </React.Fragment>
      ))}
      <div>
        <button
          onClick={() => fetchNextPage()}
          disabled={!hasNextPage || isFetching}
        >
          {isFetchingNextPage
            ? 'Loading more...'
            : hasNextPage
              ? 'Load More'
              : 'Nothing more to load'}
        </button>
      </div>
      <div>{isFetching && !isFetchingNextPage ? 'Fetching...' : null}</div>
    </>
  )
}

지속적인 fetch 가 진행 중일 때 fetchNextPage 를 호출한다면 백그라운드에서 데이터 새로고침을 덮어쓸 수 있다. 특히 list 를 렌더링하면서 fetchNextpage 를 동시에 호출한다면 더 심각해진다.

한번의 지속적인 fetch 만 Infinite Query 에 있을 수 있다. 단일 캐시 항목이 모든 페이지에 공유되므로, 동시에 여러 번의 fetch 를 시도하는 것은 데이터 덮어쓰기의 위험이 있다.

동시 fetch 를 활성화하려면, fetchNextPage{ cancelRefetch: false } 로 설정해야 한다.

충돌 없이 원활한 쿼리 프로세스를 보장하려면, 쿼리가 isFetching 상태에 있는지 확인하는 것을 강하게 추천한다.

<List onEndReached={() => hasNextPage && !isFetching && fetchNextPage()} />

Infinite Query 데이터가 stale 로 만료되어 다시 fetch 된다면, 처음부터 순차적으로 가져온다. 이는 중복이나 생략과 같은 현상을 방지할 수 있다.


Suspense

React 의 Suspense 와 함께 다음의 훅과 같이 동작할 수 있다.

  • useSuspenseQuery
  • useSuspenseInfiniteQuery
  • useSuspenseQueries
  • useQuery().promise & React.use() (실험적 기능)

suspense 모드를 사용할 때는, statuserrorReact.Suspense 컴포넌트에게 대체된다.

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

const { data } = useSuspenseQuery({ queryKey, queryFn })

반면에 쿼리의 활성화 유무를 조절할 수 없다. suspense 로 인해 컴포넌트 안의 쿼리는 직렬적으로 fetch 된다.

placeholderData 도 존재하지 않는다. 업데이트 도중 UI 가 fallback 상태로 변하는 것을 막으려면, startTransition 을 통해 쿼리 키를 변화시키는 작업을 감싸야 한다.


에러 전달

기본적으로 모든 오류가 근처의 error boundary 에 전달되지 않는다. error boundary 에게 전달되도록 하고 싶다면 명시적으로 에러를 던져야 한다.

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

const { data, error, isFetching } = useSuspenseQuery({ queryKey, queryFn })

if (error && !isFetching) {
  throw error
}

// continue rendering data

Error Boundary 초기화

쿼리 오류는 QueryErrorResetBoundary 컴포넌트나 useQueryErrorResetBoundary 훅을 통해 초기화할 수 있다.

import { QueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'

const App = () => (
  <QueryErrorResetBoundary>
    {({ reset }) => (
      <ErrorBoundary
        onReset={reset}
        fallbackRender={({ resetErrorBoundary }) => (
          <div>
            There was an error!
            <Button onClick={() => resetErrorBoundary()}>Try again</Button>
          </div>
        )}
      >
        <Page />
      </ErrorBoundary>
    )}
  </QueryErrorResetBoundary>
)

훅을 사용한다면, 가장 가까운 QueryErrorResetBoundary 의 쿼리 오류를 초기화시킨다. 만약 아무 boundary 도 없다면, 전역적으로 초기화한다.

import { useQueryErrorResetBoundary } from '@tanstack/react-query'
import { ErrorBoundary } from 'react-error-boundary'

const App = () => {
  const { reset } = useQueryErrorResetBoundary()
  return (
    <ErrorBoundary
      onReset={reset}
      fallbackRender={({ resetErrorBoundary }) => (
        <div>
          There was an error!
          <Button onClick={() => resetErrorBoundary()}>Try again</Button>
        </div>
      )}
    >
      <Page />
    </ErrorBoundary>
  )
}

Suspense on the Server with streaming

Next.js 를 사용한다면, @tanstack/react-query-next-experimental 을 통해 클라이언트 컴포넌트에서 useSuspenseQuery 를 호출해 서버에서 데이터를 fetch 할 수 있다. SuspenseBoundaries 가 확인되면 서버에서 클라이언트로 결과가 스트리밍된다.

이 기능을 사용하기 위해서는 ReactQueryStreamedHydration 컴포넌트로 앱을 감싸야 한다.

// app/providers.tsx
'use client'

import {
  isServer,
  QueryClient,
  QueryClientProvider,
} from '@tanstack/react-query'
import * as React from 'react'
import { ReactQueryStreamedHydration } from '@tanstack/react-query-next-experimental'

function makeQueryClient() {
  return new QueryClient({
    defaultOptions: {
      queries: {
        // With SSR, we usually want to set some default staleTime
        // above 0 to avoid refetching immediately on the client
        staleTime: 60 * 1000,
      },
    },
  })
}

let browserQueryClient: QueryClient | undefined = undefined

function getQueryClient() {
  if (isServer) {
    // Server: always make a new query client
    return makeQueryClient()
  } else {
    // Browser: make a new query client if we don't already have one
    // This is very important, so we don't re-make a new client if React
    // suspends during the initial render. This may not be needed if we
    // have a suspense boundary BELOW the creation of the query client
    if (!browserQueryClient) browserQueryClient = makeQueryClient()
    return browserQueryClient
  }
}

export function Providers(props: { children: React.ReactNode }) {
  // NOTE: Avoid useState when initializing the query client if you don't
  //       have a suspense boundary between this and the code that may
  //       suspend because React will throw away the client on the initial
  //       render if it suspends and there is no boundary
  const queryClient = getQueryClient()

  return (
    <QueryClientProvider client={queryClient}>
      <ReactQueryStreamedHydration>
        {props.children}
      </ReactQueryStreamedHydration>
    </QueryClientProvider>
  )
}

추가적으로 Advanced Rendering & HydrationNextJs Suspense Streaming Example 에서 더 많은 정보를 얻을 수 있다.


UseQuery().promise, React.use()

이 기능을 사용하려면 experimental_prefetchInRender 옵션을 true 로 설정하여 QueryClient 를 생성해야 한다.

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      experimental_prefetchInRender: true,
    },
  },
})
import React from 'react'
import { useQuery } from '@tanstack/react-query'
import { fetchTodos, type Todo } from './api'

function TodoList({ query }: { query: UseQueryResult<Todo[]> }) {
  const data = React.use(query.promise)

  return (
    <ul>
      {data.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  )
}

export function App() {
  const query = useQuery({ queryKey: ['todos'], queryFn: fetchTodos })

  return (
    <>
      <h1>Todos</h1>
      <React.Suspense fallback={<div>Loading...</div>}>
        <TodoList query={query} />
      </React.Suspense>
    </>
  )
}

Parallel Query

병렬 쿼리 (Parallel Query) 의 양이 변하지 않으면, 그냥 나란히 useQueryuseInfiniteQuery 훅을 사용한다면 자동으로 적용이 된다.

function App () {
  // The following queries will execute in parallel
  const usersQuery = useQuery({ queryKey: ['users'], queryFn: fetchUsers })
  const teamsQuery = useQuery({ queryKey: ['teams'], queryFn: fetchTeams })
  const projectsQuery = useQuery({ queryKey: ['projects'], queryFn: fetchProjects })
  ...
}

React Query 를 Suspence 모드에서 사용한다면, 내부적으로 첫 번째 쿼리가 Promise 를 반환해 다른 쿼리가 실행되기 전에 컴포넌트를 유예시키기 때문에 병렬 쿼리로 작동하지 않는다. useSuspenseQueries(권장) 나 각 컴포넌트의 useSuspenseQuery 인스턴스를 사용해 자체적인 병렬 처리를 구현해야 한다

useQueries 와 Dynamic Parallel Query

만약 렌더링마다 실행해야 할 쿼리의 양이 바뀐다면, 훅의 규칙을 어기기 때문에 정석적인 쿼리 작업으로 실행할 수 없다. 이럴 때는 useQueries 를 통해 원하는 쿼리들을 병렬로 실행할 수 있다.

useQueries 는 쿼리 키를 가지는 쿼리 객체의 배열을 받아 쿼리 결과의 배열을 반환한다.

function App({ users }) {
  const userQueries = useQueries({
    queries: users.map((user) => {
      return {
        queryKey: ['user', user.id],
        queryFn: () => fetchUserById(user.id),
      }
    }),
  })
}

Disabling / Pausing Query

쿼리가 자동으로 실행되는 것을 막기 위해서는, enabled = false 옵션을 사용할 수 있다. enabled 옵션은 boolean 을 반환하는 콜백도 받는다.

enabledfalse 일 때:

  • 쿼리가 캐시된 데이터를 가진다면, 쿼리가 status ==='success'isSuccess 상태로 초기화된다.
  • 쿼리가 캐시된 데이터를 가지지 않는다면, status === 'pending'fetchStatus === 'idle' 상태로 초기화된다.
  • 쿼리가 자동으로 마운트될 때 fetch 되지 않는다.
  • 쿼리가 자동으로 백그라운드에서 refetch 되지 않는다.
  • 쿼리가 쿼리 클라이언트가 쿼리 refetching 의 결과로 보통 호출되는 invalidateQueriesrefetchQueries 를 무시한다.
  • useQuery 에서 반환되는 refetch 가 쿼리를 fetch 를 유발하도록 사용될 수 있다. 하지만 skipToken 과 작동하지 않는다.

TypeScript 개발자들은 skipTokenenabled=false 의 대체제로 사용하는 것을 선호할 수도 있다

function Todos() {
  const { isLoading, isError, data, error, refetch, isFetching } = useQuery({
    queryKey: ['todos'],
    queryFn: fetchTodoList,
    enabled: false,
  })

  return (
    <div>
      <button onClick={() => refetch()}>Fetch Todos</button>

      {data ? (
        <>
          <ul>
            {data.map((todo) => (
              <li key={todo.id}>{todo.title}</li>
            ))}
          </ul>
        </>
      ) : isError ? (
        <span>Error: {error.message}</span>
      ) : isLoading ? (
        <span>Loading...</span>
      ) : (
        <span>Not ready ...</span>
      )}

      <div>{isFetching ? 'Fetching...' : null}</div>
    </div>
  )
}

쿼리를 영구적으로 비활성화하는 것은 Tanstack Query 의 많은 기능을 비활성화시키며, 선언적 접근에서 명령형 접근으로 바꾼다. 또한 refetch 로 매개변수를 전달할 수 없다. 대부분의 경우, 초기 fetch 를 지연시키는 lazy 쿼리만 필요하다.


Lazy Query

영구적인 비활성화가 아닌, 나중을 위해 활성화/비활성화 할 수도 있다.

function Todos() {
  const [filter, setFilter] = React.useState('')

  const { data } = useQuery({
    queryKey: ['todos', filter],
    queryFn: () => fetchTodos(filter),
    // ⬇️ disabled as long as the filter is empty
    enabled: !!filter,
  })

  return (
    <div>
      // 🚀 applying the filter will enable and execute the query
      <FiltersForm onApply={setFilter} />
      {data && <TodosTable data={data} />}
    </div>
  )
}

Lazy 쿼리는 status: 'pending' 으로 시작한다. 왜냐하면 pending 은 아직 아무런 데이터가 없다는 것을 의미하기 때문이다. 하지만, 아직 우리가 아무 데이터도 fetch 하고 있지 않기 때문에 pending 을 통해 로딩 스피너를 보여줄 수 없다는 것을 의미한다.

Disabled / Lazy 쿼리를 사용한다면, isLoading 을 대신 사용할 수 있다. 이는 isPending && isFetching 으로 계산된다. 그러므로 지금 처음 fetching 을 한다면 true 이다.

skipToken 을 통한 쿼리 비활성화

TypeScript 를 사용한다면, skipToken 으로 쿼리를 비활성화시킬 수 있다. 이는 조건 별로 쿼리를 비활성화하기를 원하지만 쿼리가 type-safe 하기를 원할 때 유용하다.

useQueryrefetchskipToken 과 동작하지 않는다. 그외에는 enabled: false 와 동일하게 동작한다.

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

function Todos() {
  const [filter, setFilter] = React.useState<string | undefined>()

  const { data } = useQuery({
    queryKey: ['todos', filter],
    // ⬇️ disabled as long as the filter is undefined or empty
    queryFn: filter ? () => fetchTodos(filter) : skipToken,
  })

  return (
    <div>
      // 🚀 applying the filter will enable and execute the query
      <FiltersForm onApply={setFilter} />
      {data && <TodosTable data={data} />}
    </div>
  )
}



예시 코드


공식 문서에서 아래의 내용을 포함하고 있는 글입니다.

profile
재미있는 걸 좋아합니다

0개의 댓글