React Query 더 파보기

차차·2024년 1월 10일
1

react study

목록 보기
5/5
post-thumbnail

React query (Tanstack query) 를 다시 사용하게 될 미래를 위해, 제대로 사용하기 위한 개념과 사용 패턴 정리하기! - 2탄
이전 포스트 보러가기 - React query 파보기


사용 패턴


Parallel

여러개의 쿼리를 promise.all 처럼 병렬적으로 실행하기

const result = useQueries({ queries:, queryClient:, combine:})

useQueries 옵션들

  • queries
    - useQuery 사용 시 넣어주는 쿼리 객체 배열

  • queryClient
    - 쿼리들에게 따로 구성한 커스텀 QueryClient 를 제공하기 위한 옵션 (optional)

  • combine
    - 쿼리 결과들을 단일 값으로 결합할 때 사용 (optional)
    - useQuery 의 select 옵션과 유사

1. useQueries 를 활용하여 한번에 데이터 불러오기

const results = useQueries({
  queries: [
    {
      queryKey: ["users"],
      queryFn: () => getUsers(),
    },
    {
      queryKey: ["todos"],
      queryFn: () => getTodos(),
      staleTime: 1000,
    },
  ],
});

위의 예시에서 getUsers 가 1000ms 걸리고 getTodos 가 2000ms 가 걸린다고 가정했을 때, 초기 데이터 로딩에 걸리는 시간은 약 2000ms 이다.

  • useQuery 로 각각 불러오게 될 시, 두 시간을 합한 값인 3000ms 정도가 소요된다.

쿼리에서 반환하는 객체들로 이루어진 배열을 반환한다. (result)

  • 사용할 때, 모든 상태값에 대해 result[0].isPending 이런식으로 써야하는 불편함이 있을 수 있다.
  • 이를 개발자의 입맛대로 개선하는 것이 아래에 있는 combine 옵션!

2. Combine 으로 단일 데이터 반환하기

const ids = [1, 2, 3];
const result = useQueries({
  queries: ids.map((id) => (
    { queryKey: ['post', id], queryFn: () => fetchPost(id) },
  )),
  combine: (results) => {
    return ({
      data: results.map((result) => result.data),
      pending: results.some((result) => result.isPending),
    });
  },
});

기존 반환값

result = [
	{data:, isPending:}, 
	{data:, isPending:}
]

Combine 옵션으로 결합한 반환값
(pending 과 같은 쿼리 실행 상태를 다루기 훨씬 편해졌다)

result = {
	data: [...],
	pending: true // or false
}

Dependent

특정 쿼리에 의존할 경우, 해당 쿼리가 완료될 때 까지 실행 멈추기

1. useQuery + enabled 를 사용한 종속적인 쿼리

enabled 옵션을 사용해서 특정 값이 존재할 때 까지 쿼리 실행을 막을 수 있다.

// id 값을 받아와서 요청에 포함시켜야 하는 경우

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

const userId = user?.id

const { data: projects } = useQuery({
  queryKey: ['projects', userId],
  queryFn: getProjectsByUser,

  // userId가 존재할 때까지 쿼리를 실행하지 않음
  // Boolean(userId)
  
  enabled: !!userId,  
})

2. useQueries + 삼항연산자 를 사용한 종속적인 쿼리

특정 값이 없을 때, queries 배열로 아예 빈 배열을 넣어버리는 방식이다.
queries: [] 는 실행해야 하는 쿼리들이 없다는 뜻

// [id1, id2, id3, ...]
const { data: userIds } = useQuery({
  queryKey: ['users'],
  queryFn: getUsersData,
  select: (users) => users.map((user) => user.id),
})

const usersMessages = useQueries({
  queries: userIds
    ? userIds.map((id) => {
        return {
          queryKey: ['messages', id],
          queryFn: () => getMessagesByUsers(id),
        }
      })
    : []
  // userIds 가 없다면 빈 배열 넣어줌
})

💡 주의

종속적인 쿼리는 요청 waterfall 을 형성하기 때문에, 성능에 영향을 줄 수 있다. 따라서 API 구조 자체를 바꾸는 것을 권장한다고 한다.
결국 직렬 수행은 병렬 수행보다 오래걸리기 때문이다!


Optimistic Update

mutation 이 완료되기 전에 UI 를 미리 업데이트하기

  • 캐시를 직접 미리 업데이트해서 실제 데이터 바꿔놓기
  • 반환된 variable 활용해서 UI 만 업데이트하기

1. 캐시 직접 업데이트

  • 쿼리 데이터를 직접 미리 바꾸는 방법
  • mutation 이 실패했을 경우를 대비하여, 이전 쿼리 데이터를 저장한 후 onError 에 롤백을 실행해야 한다.

oldnew 로 수정하는 mutation 의 경우,

  1. 진행 중인 데이터 업데이트 중단
  2. 업데이트 이전 old 데이터 저장
  3. 쿼리를 new 로 업데이트
  4. 에러 발생 시, 저장해놓은 old 데이터로 돌아가기
  5. 쿼리 성공 시, 실제 데이터 업데이트
useMutation({
  mutationFn: updateTodo,
  onMutate: async (newTodo) => {
    // 진행 중인 쿼리 업데이트 취소
    await queryClient.cancelQueries({ queryKey: ['todos'] });

    // 이전 쿼리값 저장
    const previousTodos = queryClient.getQueryData(['todos']);

    // mutation 으로 바뀔 새 값으로 업데이트
    queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);

    // 이전 쿼리값이 들어있는 객체 반환 (**context**)
    return { previousTodos };
  },
  onError: (err, newTodo, context) => {
    // mutation 이 실패하면 이전 쿼리값으로 롤백
	// 위에서 반환한 context 사용
    queryClient.setQueryData(['todos'], context.previousTodos);
  },
  onSettled: () => {
    // mutation 이 끝나고, 에러/성공에 상관없이 실제 쿼리 업데이트!
    queryClient.invalidateQueries({ queryKey: ['todos'] });
  },
})

2. UI 만 업데이트하기

useMutation 의 반환값인 variables 을 사용해서 UI 를 미리 바꿔줄 수 있다.

const { isPending, variables, mutate, isError } = useMutation({
  mutationFn: (newTodo: string) => axios.post('/api/data', { text: newTodo }),

  // 쿼리 업데이트가 완료될 때까지 보류시키기 위해 Promise 반환
  onSettled: async () => {
    return await queryClient.invalidateQueries({ queryKey: ['todos'] })
  },
})

(mutation → 쿼리 업데이트) 가 진행되는 동안, isPending = true 이다.

  • 따라서 isPending 일 시에만 variable 을 UI 에 보여주면 된다.

variables 은 mutationFn 에서 매개변수로 받은 값이다.

  • 예시에서는 variables = newTodo
<ul>
  {todoQuery.items.map((todo) => (
    <li key={todo.id}>{todo.text}</li>
  ))}
  {isPending && <li>{variables}</li>}
</ul>

다시 시도하는 버튼을 구성할 수도 있다.

  • mutate 에 variables 를 그대로 넣으면 이전 mutation 요청과 동일하기 때문이다.
{
  isError && (
    <li>
      {variables}
      <button onClick={() => mutate(variables)}>다시 시도하기</button>
    </li>
  )
}

Prefetching

데이터 미리 불러오기 (fetch)

  • 페이지네이션, 무한스크롤에서 주로 사용
  • 페이지1에 도착했을 때, 페이지2의 데이터 미리 fetch

1. queryClient.prefetchQuery

미리 가져와서 캐시에 쌓아놓는 방법

  • 기본적으로, staleTime 을 설정해놔야 의미가 있다.
    그래야 캐시를 쳐다보고, 데이터를 미리 캐시에 넣은 효과가 있기 때문이다.
const prefetchNextPosts = async (nextPage: number) => {
  const queryClient = useQueryClient();

	await queryClient.prefetchQuery({
    queryKey: ["posts", nextPage],
    queryFn: () => fetchPosts(nextPage),
  });
};

useEffect(() => {
  const nextPage = currentPage + 1;

  if (nextPage < maxPage) {
    prefetchNextPosts(nextPage);
  }
}, [currentPage]);

2. 이벤트 핸들러에서 prefetch

버튼 focus, hover 와 같은 이벤트에 prefetch 연결하기

  • 쿼리를 실행하는 컴포넌트가 마운트될 것 같으니, 미리 해당 쿼리를 실행하는 것!
const ShowDetailsButton = () => {
  const queryClient = useQueryClient()

  const prefetch = () => {
    queryClient.prefetchQuery({
      queryKey: ['details'],
      queryFn: getDetailsData
    })
  }

  return (
    <button onMouseEnter={prefetch} onFocus={prefetch} onClick={...}>
      상세정보 표시
    </button>
  )
}

3. 컴포넌트 내에서 데이터 prefetch 하기

컴포넌트 라이프사이클로 인해 쿼리가 종속적으로 작동하게 될 경우,
부모 컴포넌트에서 prefetch 하는 방법을 통해 해결할 수 있다고 한다.

  • 아래 예시의 경우 articlearticle-comments 가 종속적인 쿼리가 아님에도 불구하고, Comments 가 Article 의 자식 컴포넌트이기 때문에 종속적으로 실행된다. (article 쿼리 실행이 끝나야 article-comments 쿼리 실행)

  • request waterfall 이 발생한다고 할 수 있다.

// Article 컴포넌트

function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  if (isPending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      <Comments id={id} />
    </>
  )
}

// Comments 컴포넌트

function Comments({ id }) {
  const { data, isPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  // ...
}

이를 해결하기 위해 article-comment 쿼리를 부모컴포넌트인 Article 로 올린 후, Comment 컴포넌트의 prop 으로 쿼리 결과를 전달해줄 수도 있다.

하지만, prop drilling 의 가능성 + 컴포넌트 역할 뒤죽박죽 + prop 타입 선언 어려움... 등등의 이유로 인해 좋지 않을 수 있다.

따라서 이런 방법이 있다고 한다!

function Article({ id }) {
  const { data: articleData, isPending } = useQuery({
    queryKey: ['article', id],
    queryFn: getArticleById,
  })

  // Prefetch : 미리 comments 쿼리 실행해서 캐시 업데이트
  useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
    notifyOnChangeProps: [],
    // 쿼리 결과에 따른 리렌더링을 막기 위한 옵션
  })

  if (isPending) {
    return 'Loading article...'
  }

  return (
    <>
      <ArticleHeader articleData={articleData} />
      <ArticleBody articleData={articleData} />
      <Comments id={id} />
    </>
  )
}

function Comments({ id }) {
  // stale time 이 지나지 않았다면 캐싱된 데이터만 가져옴
  const { data, isPending } = useQuery({
    queryKey: ['article-comments', id],
    queryFn: getArticleCommentsById,
  })

  ...
}


이어서


글이 또 길어졌다!
다음에는 Query Observer 와 같은 친구들이 어떤 일을 하는 지 간단한 동작 원리와, invalidate Query 에 대해 정리할 계획이다.


0개의 댓글