본문 바로가기

FrontEnd

리액트 쿼리 : 리액트 라우터와 연계하기

반응형

리액트 쿼리와 리액트 라우터를 함께 효과적으로 사용하는 방법을 배워봅니다.
원문 링크입니다. https://tkdodo.eu/blog/react-query-meets-react-router

 

React Query meets React Router

React Query and React Router are a match made in heaven.

tkdodo.eu

Remix로더액션이라는 데이터 가져오기의 새로운 컨셉을 도입했습니다.
그리고 이 개념은 React Router 6.4 버전부터 순수한 CSR에서도 사용할 수 있게 되었습니다.

URL 세그먼트, 레이아웃, 데이터는 좋은 짝꿍입니다.
loader는 리액트 쿼리 등 데이터 가져오기를 url 단위로 캡슐화 해주는 요소입니다.
action은 폼 제출(submit)을 에뮬레이트 하는 녀석입니다.
앱이 라우터에 non-get submit("post", "put", "patch", "delete")을 보낼 때마다 액션이 호출됩니다.

(튜토리얼 살펴보기 : tutorial)
React Router가 데이터 가져오기 게임에 등장하면서
React Query와 같은 기존 데이터 가져오기 및 캐싱 라이브러리와 어떻게 경쟁하거나 상관 관계가 있는지
이해하는 것은 자연스럽습니다.

리액트 라우터와 리액트 쿼리는 환상의 짝궁입니다.

데이터를 가져오는 라우터

React Router를 사용하면
  • 각 경로(route)에 loaders를 정의할 수 있으며,
  • 로더는 경로를 방문할 때 호출됩니다.
  • 경로 컴포넌트에서 LoaderData()를 사용하여 해당 데이터에 액세스할 수 있습니다.
  • 데이터 업데이트는 양식을 제출하는 것만큼 간단합니다. 양식 제출은 action 함수를 호출합니다.
  • 액션은 모든 활성 로더를 invalidate하므로 화면에 업데이트된 최신 데이터가 자동으로 표시됩니다.
주 : 리액트 쿼리의 invalidateQueries는 해당 쿼리를 stale 한 것으로 만들어
해당 쿼리를 이용한 리렌더링이 발생하면 api 요청을 다시 해 새로운 데이터를 자동으로 현재 UI에 반영하게 합니다.
invalidate한다고 즉시 반영되는것은 아닙니다.
참고 : https://tanstack.com/query/v4/docs/guides/query-invalidation

이는 쿼리 / 뮤테이션과 매우 유사합니다.
Remixing React Router 발표 이후 아래와 같은 의문을 품는 것은 당연합니다.

  • 이제 경로에서 데이터를 가져올 수 있는데 React Query가 필요할까요?
  • 현재 React Query를 사용하고 있는데, 새로운 React Router 기능을 활용할 필요가 있을까요?

저는 두 질문에 전부 yes라 대답하겠습니다.


로더에는 캐시 기능이 없습니다 : when과 what

리믹스 팀의 Ryan Florence는 "React Router는 캐시가 아닙니다"라고 말했습니다.

클라이언트에는 이미 리액트 쿼리와 같은 캐싱 솔루션이 존재합니다.

데이터를 "가능한 한 빨리-when" 가져오는 것은 최상의 사용자 경험을 제공하기 위한 중요한 개념입니다.
nextjs 또는 Remix와 같은 풀 스택 프레임워크는 이 단계를 서버로 이동합니다.
왜냐하면 이것이 가장 빠른 진입점이기 때문입니다.
클라이언트 렌더링 애플리케이션에서는 이러한 사치를 부릴 수 없습니다.

데이터 일찍 가져오기

우리는 일반적으로 컴포넌트 마운트 시 데이터를 가져옵니다.
이는 좋지 않습니다. 로딩 스피너가 필요하기 때문입니다.
프리패칭(Prefetching)은 후속 탐색에만 적용 가능하며, 모든 경로에 수동 적용이 필요합니다.

라우터는 방문하려는 페이지를 항상 알고 있는 첫 번째 컴포넌트이며
이제 로더가 있으므로 해당 페이지에서 렌더링해야 하는 데이터를 알 수 있습니다.
이것은 첫 번째 페이지 방문에 유용하지만, 페이지 방문 시마다 로더가 호출될 수 있습니다
라우터에는 캐시가 없기 때문입니다.
우리가 이에 대해 조치를 취하지 않는 한 매 요청은 계속 서버에 다시 도달합니다.

예를 들어(이전에 언급된  tutorial에서 가져온 것입니다.) 연락처 목록이 있다고 가정합니다.
그 중 하나를 클릭하면 연락처 세부 정보가 표시됩니다.

연락처(Contact) 컴포넌트

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

// ⬇️ 세부 경로를 위한 로더입니다.
export async function loader({ params }) {
  return getContact(params.contactId)
}

export default function Contact() {
  // ⬇️ 로더에서 데이터를 가져옵니다.
  const contact = useLoaderData()
  // render some 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 />,
            // ⬇️ 세부 경로에 로더 사용을 설정합니다.
            loader: contactLoader,
          },
        ],
      },
    ],
  },
])
contact/1로 이동하면 컴포넌트가 렌더링되기 전에 해당 연락처에 대한 데이터를 가져옵니다.
즉, Contact 컴포넌트를 표시하려는 시점에는 useLoaderData에 데이터를 이미 사용할 수 있습니다.
이것은 사용자 경험을 향상시킬 뿐만 아니라
동시에(co-located) 데이터 가져오기 및 렌더링애 관련한 개발자 경험을 개선하기 때문에 훌륭합니다 🥰.

데이터를 너무 자주 가져오는 것

캐시가 없다는 큰 단점은 Contact 2로 갔다가 다시 Contact 1로 돌아갈 때 나타납니다.
React Query에 익숙하다면 Contact 1에 대한 데이터가 이미 캐시되어 있다는 것을 알 것입니다.
데이터가 오래된(stale) 것으로 간주되면 캐시를 즉시 화면에 보여줌과 동시에 백그라운드에서 다시 가져올 수 있습니다.
로더 접근 방식을 사용하면 이전에 이미 가져왔음에도 불구하고 해당 데이터를 다시 가져와야 합니다.
(그리고 가져오기가 완료될 때까지 기다려야 합니다!).

이제 React Query가 등장할 때입니다!

로더를 사용하여 React Query Cache를 미리 채우고
컴포넌트에서 useQuery를 사용하여 refetchOnWindowFocus와 같은 모든 React Query의 좋은점을 활용하면서,
stale한 데이터를 캐시를 통해 보여주면 어떨까요?
 
라우터는 데이터를 일찍 가져오는 역할을 하고(없는 경우) - when(fetch)
React Query는 데이터를 최신 상태로 캐싱하고 유지하는 역할을 합니다. -what(data)

예제로 확인하기

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는 기존처럼 사용합니다.
  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 />,
            // ⬇️ 로더에 쿼리클라이언트를 넘겨줍니다. (qc)=>({param})=>any
            loader: contactLoader(queryClient),
          },
        ],
      },
    ],
  },
])
좀 더 자세히 살펴봅시다.

로더는 QueryClient 접근이 필요합니다.

로더는 훅이 아니므로 QueryClient를 사용할 수 없습니다.
QueryClient를 직접 가져오는 것은 권장하지 않는 것(I'm not recommending)이므로
파라미터로 명시적으로 전달하는 것이 가장 좋은 대안인 것 같습니다.


getQueryData ?? fetchQuery

첫 번째 로드에서 좋은 경험을 얻기 위해
우리는 로더가 데이터가 준비될 때까지 기다렸다가 데이터를 반환하기를 원합니다. (for no spinner)
또한 errorElement에 오류가 발생하기를 원하므로 fetchQuery가 최상의 옵션입니다.
prefetchQuery는 아무 것도 반환하지 않고 내부적으로 오류를 처리합니다. (그렇지 않으면 fetchQuery와 동일함).
getQueryData는 캐시에 있는 모든 데이터가 오래된(stale) 경우에도 반환하는 트릭을 수행합니다.
이렇게 하면 같은 페이지를 반복적으로 방문하면 데이터가 즉시 표시됩니다.
getQueryData가 undefined를 반환하는 경우, (캐시에 아무것도 없음을 의미),
사바에서 데이터 가져오기를 수행합니다.
다른 방법은 fetchQuery에 대해 staleTime을 설정하는 것입니다.
export const loader =
  (queryClient) =>
  ({ params }) =>
    queryClient.fetchQuery({
      ...contactDetailQuery(params.contactId),
      staleTime: 1000 * 60 * 2,
    })
staleTime을 2분으로 설정하면
데이터가 사용 가능하며 데이터를 가져온지 2분이 지나지 않은 경우, 즉시 데이터를 반환하도록 fetchQuery에 지시합니다.
그렇지 않으면 데이터를 가져와서 보여줍니다.
stale한 데이터가 컴포넌트에 표시되지 않아도 괜찮다면 이것이 좋은 대안입니다.

getQueryData 접근 방식은 staleTime을 Infinity로 설정하는 것과 거의 동일합니다.
(수동 쿼리 무효화가 staleTime보다 우선한다는 점을 제외하고 )
따라서 코드가 약간 더 많더라도 getQueryData 접근 방식이 더 좋습니다.

주 : 이렇게 하면 무조건 캐시에서 가져오기 떄문에 적절하게 invalidate를 위한 수단이 필요합니다.

타입스크립트 팁

이를 통해 컴포넌트에서 useQuery를 호출하면 useLoaderData를 호출하는 것처럼 데이터를 사용할 수 있습니다.
그러나 TypeScript는 이를 알 수 있는 방법이 없습니다.

반환된 데이터 타입은 Contact | undefined 입니다.

Matt Pocock의 React Query v4를 위한 contribution 덕택에
initialData가 제공되면 undefined를 유니온에서 제외할 수 있습니다.

useLoaderData에서 초기값을 얻을 수 있으며, 로더 함수에서 타입을 유추할 수도 있습니다.
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
}
우리의 로더는 함수를 반환하는 함수이기 때문에 작성해야 할 것이 조금 많지만 단일 유틸리티에 넣을 수 있습니다.
또한 현재로서는 type assertion을 사용하는 것이 useLoaderData의 반환 타입을 좁힐 수 있는 유일한 방법인 것 같습니다. 🤷‍♂️
하지만 우리가 원하는 useQuery 결과 타입으로 타입 추론 결과를 멋지게 좁힐 것입니다. 🙌

액션에서 쿼리 무효화(Invalidating)

퍼즐의 다음 조각은 쿼리 무효화를 포함합니다.
다음은 React Query 없이 액션이 구현되는 모습입니다.
(예, 업데이트를 수행하는 데 필요한 모든 것입니다).

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(["contacts"]);
    return redirect(`/contacts/${params.contactId}`)
  }

invalidateQueries의 퍼지 일치(fuzzy matching of invalidateQueries)는
액션이 완료되고 세부 정보 보기로 다시 리디렉션될 때까지
목록과 세부 정보 보기가 캐시에 새 데이터를 가져오도록 합니다.


await은 모드 전환 레버

그러나 이렇게 하면 액션 함수의 실행이 더 오래 걸리고 경로 전환이 차단됩니다.
무효화를 트리거한 다음 세부 정보 보기로 리디렉션하고 stale한 데이터를 표시한 다음
새 데이터를 사용할 수 있게 되면 백그라운드에서 업데이트하도록 할 수 없습니까?
물론 다음과 같이 할 수 있습니다.
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(["contacts"]);
    return redirect(`/contacts/${params.contactId}`)
  }​

Await는 말 그대로 양방향으로 당기는 레버(지렛대)가 됩니다
(이 비유는 Ryan의 When To Fetch에 기반합니다. 아직 시청하지 않은 경우 시청하십시오):

  • 가능한 한 빨리 상세 보기로 다시 전환하는 것이 중요합니까?
    • await을 사용하지 마세요.
  • 오래된 데이터를 표시할 경우 발생할 수 있는 잠재적인 레이아웃 변경(shift)을 피하는 것이 중요한가요?
  • 모든 새 데이터가 있을 때까지 액션 실행을 보류하고 싶나요?
    • await을 사용합니다.

두 가지 접근 방식을 섞어 사용해,

중요한 다시 가져오기는 전체를 다 기다릴 수도 있지만 덜 중요한 것은 백그라운드에서 수행되도록 할 수 있습니다. (캐시 데이터를 보여주며)


요약

새로운 React Router 릴리스에 대해 매우 기쁩니다.
모든 애플리케이션이 가능한 한 데이터를 빨리 가져올 수 있는 것은 큰 진전입니다.
그러나 이 솔루션이 캐싱을 대체하는 것은 아니므로
React Router와 React Query를 결합하여 두 세계의 장점을 최대한 활용하세요. 🚀
이 주제를 더 탐색하고 싶다면 아래 튜토리얼을 참고하세요. : the examples of the official docs 
반응형