[번역] ReactQuery와 React-Router(v6.4) 같이 써서 data 미리 fetching하기

nyoung·2023년 9월 12일
4

번역

목록 보기
1/1

React Query meets React Router 를 번역하며 공부

React-Router에 loaders 와 actions가 도입되었다. React Router가 data fetching에 관여하면서, React Query와 같은 기존 데이터 페칭 및 캐싱 라이브러리와 어떻게 호환 또는 경쟁하는지 알고 싶어졌다.

데이터를 패칭하는 라우터

React-Router는 각각의 route에 loaders를 정의할 수 있게 한다. route component 그 자체로, useLoaderData() 를 사용하면 data에 access할 수 있게 한다. Form 과 같이 데이터를 업데이트하는 것은 action function을 불러일으킬 수 있다. Actions는 모든 활성화된 loaders를 무효화하고, 당신의 화면에 데이터를 업데이트 시킬 수 있다.

이는 queriesmutations 과 비슷하게 들린다.

  • React Query를 계속 사용하게 될까?
  • 이미 React Query를 사용하고 있는데, React Router의 새로운 기능을 사용하게 될까?

cache가 아님

나는 그렇다고 생각한다. remix 팀의 Ryan Florence가 말한 것 처럼, React Router은 cache가 아니기 때문이다.

Browers는 HTTP에 이 기능이 내장되어 있으며., React Query와 같은 라이브러리에는 이 작업이 잘 정의되어있다.

React-Router는 언제에 관한 것이며, 데이터 캐싱 라이브러리는 무엇에 관한 것이다.

가능한 한 빨리 데이터 패칭을 하는 것은 사용자 경험에 중요하다. Full Stack Framework(Next.js 같은)는 이 과정을 server로 옮기는데, CSR는 그렇지 않다.

미리 fetch하기

우리가 보통 하는 것은 컴포넌트가 마운트되고 데이터가 처음 필요할 때 페칭하는 것이다. 이것은 좋지 않다.

처음 데이터를 패칭할 때 로딩 스피너 등을 유저가 봐야 하기 때문이다. React-Query의 Prefetching은 도움을 줄 수 있지만, 후속적인 navigation이나 route에 대해 모든 경로에 대해 수동적으로 적어줘야 한다.

그러나 router는 사용자가 방문하려는 페이지를 항상 파악하는 첫 번째 구성 요소이고, 이제는 loades 가 있으니 해당 페이지에서 렌더링해야하는 데이터를 파악할 수 있다. 이는 첫 페이지 방문에 유용하다. 그러나 loader는 페이지를 방문할 때 마다 호출된다. router에는 캐시가 없기 때문에 별도의 조치를 취하지 않는 한 서버에 다시 호출된다.

아래의 예시에서, 연락처 목록에서 연락처 Detail을 볼 수 있다.

src/routes/contact.jsx

import { useLoaderData } from 'react-router-dom'
import { getContact } from '../contacts'

// ⬇️ this is the loader for the detail route
export async function loader({ params }) {
  return getContact(params.contactId)
}

export default function Contact() {
  // ⬇️ this gives you data from the loader
  const contact = useLoaderData()
  // render some jsx
}

src/main.jsx

import Contact, { loader as contactLoader } from './routes/contact'

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    children: [
      {
        path: 'contacts',
        element: <Contacts />,
        children: [
          {
            path: 'contacts/:contactId',
            element: <Contact />,
            // ⬇️ this is the loader for the detail route
            loader: contactLoader,
          },
        ],
      },
    ],
  },
])

만약 contacts/1 로 navigate된다면, contact의 데이터는 컴포넌트가 패치 되기 전에 렌더링된다. Contact 컴포넌트를 보여주기 전, useLoaderData 는 data를 읽을 수 있게 만든다. 이는 사용자 경험을 올릴 뿐 아니라, 개발자 경험도 올린다.

너무 자주 Fetching한다

캐시가 없다는 것의 가장 큰 약점은 Contact/2로 갔다가 다시 /1로 갔을 때 벌어진다. 만약 React Query를 사용하면, 당신은 Contact 1의 데이터가 이미 캐시되어 있다는 것을 알기 때문에, 데이터가 오래된 것으로 간주되면 데이터를 보여주고 즉시 백그라운드 리패치를 시작하는 것을 알 수 있다.

loader 접근 방식을 사용하면 해당 데이터를 다시 불러와야 한다. 그리고 불러오기가 완료될 때까지 기다려야 한다. 이미 우리가 data fetch를 이전에 이미 했을 때도 마찬가지이다.

여기서 React Query를 쓰면 좋다. loader를 사용해 미리 React Query Cache를 채우되, 컴포넌트에서 여전히 useQuery를 사용해 refetchOnWindowFocus와 Stale data를 즉시 표시하는 것과 같은 모든 React Query 기능을 얻을 수 있다면 어떨까?

라우터는 데이터가 없는 경우 데이터를 조기에 가져오는 역할을 담당하고, React Query는 데이터를 캐싱하고 최신 상태로 유지하는 역할을 담당한다.

예시 쿼리

src/routes/contacts.jsx


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

const contactDetailQuery = (id) => ({
	queryKey: ['contacts', 'detail', id],
  queryFn: async () => getContact(id),
}) 

export const loader = (queryClient) => async ({params}) => {
	const query = contactDetailQuery(params.contactId)

	// ⬇️ return data or fetch it
    return (
      queryClient.getQueryData(query.queryKey) ??
      (await queryClient.fetchQuery(query))
    )
}

export default function Contact() {
  const params = useParams()
  // ⬇️ useQuery as per usual
  const { data: contact } = useQuery(contactDetailQuery(params.contactId))
  // render some jsx
}

src/main.jsx

const queryClient = new QueryClient()

const router = createBrowserRouter([
  {
    path: '/',
    element: <Root />,
    children: [
      {
        path: 'contacts',
        element: <Contacts />,
        children: [
          {
            path: 'contacts/:contactId',
            element: <Contact />,
            // ⬇️ pass the queryClient to the route
            loader: contactLoader(queryClient),
          },
        ],
      },
    ],
  },
])

loader는 QueryClient에 접근 가능해야 한다.

loader는 hook이 아니기 때문에, useQueryClient 를 할 수 없다. QueryClient 를 직접적으로 import 하는 것은 저자가 추천하는 방식이 아니기 때문에, 가장 나은 방법은 paramater으로 넘겨주는 것이다.

getQueryData ?? fetchingQuery

우리는 loader를 data가 준비될 때 까지 기다리게 만들어 처음 로드 시 좋은 사용자 경험을 만들게 하고 싶다. 우리는 또한 error 발생 시 errorElement에 던지기를 원한다.

따라서 fetchquery 가 최고의 선택이다. prefetchQuery 는 아무것도 반환하지 않고 내부적으로 에러를 잡는다는 것을 기억하자. (그렇지 않고는 fetchQuery와 동일한 로직이 된다)

getQueryData 는 data가 cache에 저장되어있으면 stale하더라도 가져온다. 이것은 어떤 페이지를 다시 접근하더라도 바로 데이터를 보여줄 수 있음을 의미한다.

getQueryData 가 undefined를 return할 때만(캐시가 없을 때)만 패치한다.

export const loader =
  (queryClient) =>
  ({ params }) =>
    queryClient.fetchQuery({
      ...contactDetailQuery(params.contactId),
      staleTime: 1000 * 60 * 2,
    })

staleTime을 2분 동안 저장하는 것은 fetchQuery를 2분 동안 즉시 데이터를 리턴하고, 아니면 패칭하는 것을 의미한다. 만약 stale한 data를 컴포넌트에 보여주고 싶지 않으면 이것은 괜찮은 대안이다.

staleTimeInfinity 로 설정하는 것은 getQueryData 접근 방식과 거의 동일하다. 수동으로 쿼리 무효화를 하는 것이 staleTime보다 우선한다는 점을 제외하면 말이다. 따라서 나는 코드가 더 많을지라도 getQueryData 접근 방식을 선호한다.

업데이트: v4.18.0부터 내장된 queryClient.ensureQuery 메서드를 사용하여 동일한 작업을 수행할 수 있다. 이 메서드는 말 그대로 getQueryData ?? fetchQuery로 구현되지만, 라이브러리에서 바로 사용할 수 있을 만큼 충분히 일반적이다.

TypeScript를 위한 팁

useLoaderData 를 부르기 때문에 컴포넌트 안에서 useQuery 를 호출하는 것은 data를 가지고 있다고 확신하지만, Typescript는 알 수 없다. 따라서 데이터의 타입은 Contact | undefined 가 될 것이다.

initialData 를 사용하면 undefined를 제거할 수 있다.

Copyinitial-data: copy code to clipboard
export default function Contact() {
  const initialData = useLoaderData() as Awaited<
    ReturnType<ReturnType<typeof loader>>
  >
  const params = useParams()
  const { data: contact } = useQuery({
    ...contactDetailQuery(params.contactId),
    initialData,
  })
  // render some jsx
}

loader가 함수를 반환하는 함수이기 때문에, 작성하는 것이 복잡하지만 단일 유틸리티에 넣을 수 있고, type assertion을 사용하는 것이 현재로서는 유일한 방법이다.

actions 무효화하기

다음 문제는 action을 무효화하는 것이다. 아래와 같이 해당 action은 loader를 무효화하지만, 로더가 항상 캐시를 반환하기 때문에 return 값은 변하지 않는다.

// src/routes/edit.jsx

export const action = async ({ request, params }) => {
  const formData = await request.formData()
  const updates = Object.fromEntries(formData)
  await updateContact(params.contactId, updates)
  return redirect(`/contacts/${params.contactId}`)
}
export const action =
  (queryClient) =>
  async ({ request, params }) => {
    const formData = await request.formData()
    const updates = Object.fromEntries(formData)
    await updateContact(params.contactId, updates)
    await queryClient.invalidateQueries({ queryKey: ['contacts'] })
    return redirect(`/contacts/${params.contactId}`)
  }

invalidateQueries 의 fuzzy matching 은 작업이 완료되고 detail페이지로 다시 redirection될 대 목록과 세부 정보 보기가 캐시에 새 데이터를 가져올 수 있게 만든다.

await is the lever

그러나 이렇게 하면 action function이 더 오래걸리고 transition이 차단된다. invalidation이 trigger된 다음, detail view로 redirection하고 staled된 데이터를 표시한 다음 새 데이터를 사용할 수 있게 되면 백그라운드에서 업데이트하도록 하면 안될까?

await 키워드를 빼면 된다.

export const action =
  (queryClient) =>
  async ({ request, params }) => {
    const formData = await request.formData()
    const updates = Object.fromEntries(formData)
    await updateContact(params.contactId, updates)
    queryClient.invalidateQueries({ queryKey: ['contacts'] })
    return redirect(`/contacts/${params.contactId}`)
  }

가능한 한 빨리 detail 페이지로 다시 전환하는 것이 중요하다면 기다리지 않아도 된다.

stale된 데이터를 표시할 때 잠재적인 레이아웃 전환을 피하는 것이 중요할까, 아니면 모든 새 데이터를 가져올 때까지 작업을 보류하고 싶다면? await을 사용하면 된다.

여러 invalidation이 관련된 경우 두 가지 접근 방식을 혼합하여 중요한 다시 가져오기를 기다리되, 덜 중요한 작업은 백그라운드에서 수행하도록 할 수도 있다.

새로운 React 라우터 릴리스에 대해 매우 기대가 된다. 모든 애플리케이션이 가능한 한 빨리 가져오기를 트리거할 수 있게 된 것은 큰 진전이다. 하지만 캐싱을 대체하는 것은 아니므로 React Router와 React Query를 함께 사용하면 두 가지 장점을 모두 누릴 수 있다.

아래는 공식 홈페이지의 예제이다.
React Query React Router Example | TanStack Query Docs

profile
코드는 죄가 없다,,

0개의 댓글