두 쿼리가 의존관계가 있어 어떤 특정 순서대로 실행이 되어야 하는 경우에는 useQuery()
의 enabled
옵션을 사용하면 된다.
enabled
옵션을 사용하면 enabled
값이 true가 되어야만 해당 쿼리가 실행되는데 이렇게 특정 값이나 조건이 충족된 이후에 실행되는 쿼리를 Dependant Query라 한다.
만약 어떤 쿼리가 userId 값이 있을 때만 실행하도록 하고 싶으면 다음과 같이 설정해 주면 된다. 그러면 userId가 있는 경우 true, 없는 경우 false로 옵션값이 설정됨
const { data: user } = useQuery({
queryKey: ['user', email],
queryFn: getUserByEmail,
});
const userId = user?.id
const {
data: projects,
} = useQuery({
queryKey: ['projects', userId],
queryFn: getProjectsByUser,
enabled: !!userId,
});
위 상황에서는 두 개의 쿼리를 순서대로 실행하려고 enabled
옵션을 사용했지만 실제로 enabled
옵션은 어떤 쿼리를 자로 실행하지 않고 특정한 값이 있거나 특정 상황이 되었을 때 실행하도록 하는 등, 다양한 시나리오에서 활용할 수 있다.
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getPosts, uploadPost, getUserInfo } from './api';
function HomePage() {
const [currentUsername, setCurrentUsername] = useState('');
// ...
const { data: userInfoData, isPending: isUserInfoPending } = useQuery({
queryKey: ['userInfo'],
queryFn: () => getUserInfo(currentUsername),
enabled: !!currentUsername,
});
// ...
const handleLoginButtonClick = () => {
setCurrentUsername('codeit');
};
const loginMessage = isUserInfoPending
? '로그인 중입니다...'
: `${userInfoData?.name}님 환영합니다!`;
if (isPending) return '로딩 중입니다...';
if (isError) return '에러가 발생했습니다.';
const posts = postsData?.results ?? [];
return (
<>
<div>
{currentUsername ? (
loginMessage
) : (
<button onClick={handleLoginButtonClick}>codeit으로 로그인</button>
)}
<form onSubmit={handleSubmit}>
<textarea
name="content"
value={content}
onChange={handleInputChange}
/>
<button disabled={!content} type="submit">
업로드
</button>
</form>
</div>
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.user.name}: {post.content}
</li>
))}
</ul>
</div>
</>
);
}
export default HomePage;
포스트를 받아오는 API 함수를 page와 limit 값을 받도록 변경
export async function getPosts(page = 0, limit = 10) {
const response = await fetch(`${BASE_URL}/posts?page=${page}&limit=${limit}`);
return await response.json();
}
useState()를 사용해 page 변수를 만들어준다. useQuery()에서 다음과 같이 queryKey에 page를 추가해 page별로 데이터를 캐싱하도록 하고 쿼리 함수 호출 부분에 page를 추가한다.
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getPosts, uploadPost, getUserInfo } from './api';
const PAGE_LIMIT = 3;
function HomePage() {
// ...
const [page, setPage] = useState(0);
const {
data: postsData,
isPending,
isError,
} = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts(page, PAGE_LIMIT),
});
// ...
const posts = postsData?.results ?? [];
return (
<>
<div>
{currentUsername ? (
loginMessage
) : (
<button onClick={handleLoginButtonClick}>codeit으로 로그인</button>
)}
<form onSubmit={handleSubmit}>
<textarea
name="content"
value={content}
onChange={handleInputChange}
/>
<button disabled={!content} type="submit">
업로드
</button>
</form>
</div>
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.user.name}: {post.content}
</li>
))}
</ul>
</div>
</>
);
}
export default HomePage;
리액트 쿼리 개발자 도구로 posts 데이터를 살펴보면 hasMore라는 값이 있는데, 백엔드에서 그다음 페이지가 있을 때 hasMore값을 true로 보내 주게 된다.
이 hasMore라는 값으로 다음 페이지 버튼을 비활성화할지 말지를 결정할 수 있다.
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getPosts, uploadPost, getUserInfo } from './api';
const PAGE_LIMIT = 3;
function HomePage() {
// ...
const [page, setPage] = useState(0);
const {
data: postsData,
isPending,
isError,
} = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts(page, PAGE_LIMIT),
});
// ...
const posts = postsData?.results ?? [];
return (
<>
<div>
{currentUsername ? (
loginMessage
) : (
<button onClick={handleLoginButtonClick}>codeit으로 로그인</button>
)}
<form onSubmit={handleSubmit}>
<textarea
name="content"
value={content}
onChange={handleInputChange}
/>
<button disabled={!content} type="submit">
업로드
</button>
</form>
</div>
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>
{post.user.name}: {post.content}
</li>
))}
</ul>
<div>
<button
disabled={page === 0}
onClick={() => setPage((old) => Math.max(old - 1, 0))}
>
<
</button>
<button
disabled={!postsData?.hasMore}
onClick={() => setPage((old) => old + 1)}
>
>
</button>
</div>
</div>
</>
);
}
export default HomePage;
리액트 쿼리에서는 좀 더 부드러운 UI 전환을 위해 placeholderData 라는 것을 설정해 줄 수 있다.
useQuery()에서 placeholderData 옵션에 keepPreviousData 혹은 (prevData) => prevData를 넣어주면 페이지가 새로 바뀌더라도 매번 pending 상태가 되지 않고, 이전의 데이터를 유지해서 보여주다가 새로운 데이터 fetch가 완료되면 자연스럽게 새로운 데이터로 바꿔서 보여주게 된다.
import {
// ...
keepPreviousData,
} from '@tanstack/react-query';
const {
data: postsData,
isPending,
isError,
} = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts(page, PAGE_LIMIT),
placeholderData: keepPreviousData,
});
그런데 이때 중간 과정에서 다음 페이지 버튼이 활성화된 채로 있는데, 현재 보이는 데이터가 이전 데이터, 즉 placeholderData라면 다음 페이지 버튼을 비활성화해 주도록 하자 (그렇지 않으면 유저가 다음 페이지 버튼을 마구 누르는 경우, 존재하지 않는 페이지로 리퀘스트가 갈 수도 있음)
useQuery()의 리턴 값에서 isPlaceholderData 값을 활용하면 된다.
const {
data: postsData,
isPending,
isError,
isPlaceholderData,
} = useQuery({
queryKey: ['posts', page],
queryFn: () => getPosts(page, PAGE_LIMIT),
placeholderData: keepPreviousData,
});
...
return (
...
<div>
<ul>
{posts.map((post) => (
<li key={post.id}>{`${post.user.name}: ${post.content}`}</li>
))}
</ul>
<div>
<button
disabled={page === 0}
onClick={() => setPage((old) => Math.max(old - 1, 0))}
>
<
</button>
<button
disabled={isPlaceholderData || !postsData?.hasMore}
onClick={() => setPage((old) => old + 1)}
>
>
</button>
</div>
</div>
);
좀 더 심리스한 유저 인터페이스를 만들고 싶다면 데이터를 prefetch하는 방법도 있다.
다음과 같이 쿼리 클라이언트의 prefetchQuery 함수를 이용하면, 현재 내가 보고 있는 페이지가 1 페이지여도 백그라운드에서 2 페이지 데이터를 미리 fetch해 놓기 때문에 다음 페이지로 갈 때 전혀 어색함이나 끊김이 없이 2 페이지의 데이터를 보여줄 수 있다.
...
useEffect(() => {
if (!isPlaceholderData && postsData?.hasMore) {
queryClient.prefetchQuery({
queryKey: ['posts', page + 1],
queryFn: () => getPosts(page + 1, PAGE_LIMIT),
});
}
}, [isPlaceholderData, postsData, queryClient, page]);
...
'posts'쪽은
QUERY_KEYS.COMMENTS
이런 느낌으로
useInfiniteQuery
를 이용해서 더 불러오기 버튼을 쉽게 구현할 수 있다.
기존에 사용하던 useQuery()를 useInfiniteQuery()로 바꿔준다.
이때 useQuery()와는 달리 useInfiniteQuery()에서는 initialPageParam과 getNextPageParam 옵션을 설정해 줘야 하는데, 페이지네이션과는 달리 페이지 별로 데이터를 별도로 저장하지 않고 전체 포스트를 한 번에 관리할 것이기 때문에 쿼리 키는 다시 ['posts']로 변경해 준다.
쿼리 함수도 pageParam이라는 값을 받아서 백엔드에 전달할 페이지값으로 사용하도록 변경해 준다.
import {
// ...
useInfiniteQuery,
} from '@tanstack/react-query';
//
const {
data: postsData,
isPending,
isError,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => getPosts(pageParam, PAGE_LIMIT),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.hasMore ? lastPageParam + 1 : undefined,
});
useQuery()에서는 data가 백엔드에서 받아 온 하나의 페이지 정보만 담고 있지만, useInfiniteQuery()에서는 data.pages에 배열의 형태로 모든 페이지의 정보를 담고 있다.
그런데 useInfiniteQuery()에서는 data.pages 라는 배열 안에 지금까지 받아 온 모든 페이지의 데이터가 담기게 된다.
맨 처음 첫 번째 페이지의 데이터를 받아오면 data.pages 배열의 0번 인덱스에 해당 데이터가 저장되고, 두 번째 페이지로 넘어가면 data.pages 배열의 1번 인덱스에 데이터가 저장되는 식이다.
하나의 배열에 두 페이지의 데이터들이 모두 담겨있으므로 첫 번째와 두 번째 데이터를 한 번에 화면에 보여 줄 수 있는데요. 이런 식으로 더 불러오기를 구현할 수 있다. 이 데이터들은 ['posts']라는 하나의 쿼리 키로 캐싱된다.
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.hasMore ? lastPageParam + 1 : undefined,
initialPageParam
: 초기 페이지 설정값
getNextPageParam
: 다음 페이지의 설정값
getNextPageParam()에서는 파라미터로 받은 값들의 정보를 이용해 그다음 페이지 값인 pageParam을 리턴해야 한다.
다음 페이지를 불러오려면 useInfiniteQuery()의 리턴 값 중 하나인 fetchNextPage() 함수를 이용하면 된다.
fetchNextPage() 함수를 실행하면 getNextPageParam() 함수의 리턴 값이 undefined나 null이 아닌 경우, 해당 리턴 값을 쿼리 함수의 pageParam으로 전달해 그다음 페이지 데이터를 가져온다.
const {
data: postsData,
isPending,
isError,
fetchNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => getPosts(pageParam, PAGE_LIMIT),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.hasMore ? lastPageParam + 1 : undefined,
});
// ...
return (
...
<div>
<button onClick={fetchNextPage}>더 불러오기</button>
</div>
...
);
기존에는 postsData에 페이지 하나에 해당하는 포스트 데이터만 들어 있었는데, 이제는 postsData 안에 pages라는 배열에 모든 페이지의 포스트 데이터가 담겨 있다. 따라서 pages 배열을 Array.map() 함수를 통해 돌면서, 각 페이지에 해당하는 포스트 데이터를 모두 보여 주도록 변경하였다. Post 컴포넌트를 새로 만들어 포스트 관련 내용은 해당 컴포넌트로 옮겨주었다.
HomePage.js
// ...
const postsPages = postsData?.pages ?? [];
return (
...
<div>
<ul>
{postsPages.map((postPage) =>
postPage.results.map((post) => <Post key={post.id} post={post} />)
)}
</ul>
<div>
...
);
Post.js
function Post({ post }) {
return (
<li key={post.id}>
{post.user.name}: {post.content}
</li>
);
}
export default Post;
const {
data: postsData,
isPending,
isError,
hasNextPage,
fetchNextPage,
isFetchingNextPage,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam }) => getPosts(pageParam, PAGE_LIMIT),
initialPageParam: 0,
getNextPageParam: (lastPage, allPages, lastPageParam, allPageParams) =>
lastPage.hasMore ? lastPageParam + 1 : undefined,
});
// ...
return (
...
<button
onClick={fetchNextPage}
disabled={!hasNextPage || isFetchingNextPage}
>
더 불러오기
</button>
...
);
옵티미스틱 업데이트는 좋아요 기능과 같이 유저에게 빠른 피드백을 제공해야 하는 경우에 사용한다.
한마디로 서버로부터의 리스폰스를 기다리지 않고 유저에게 바로 낙관적인 피드백을 주는것이다. (좋아요 버튼 누르면 유저에게 바로 좋아요 버튼을 누른 것처럼 버튼을 활성화해서 보여주는 것 같은)
실제 뮤테이션 리퀘스트를 보내기 전에 기존의 캐시 데이터를 조작해서 새로운 데이터를 반영해 유저에게 먼저 보여주고, 그 이후에 뮤테이션이 끝나면 서버에 반영된 데이터를 refetch해서 최신 데이터로 동기화하도록 하겠다.
Post 컴포넌트 안에서 좋아요 개수와 현재 유저의 좋아요 여부에 대한 데이터를 받아오는 useQuery()
추가
import { useQuery } from '@tanstack/react-query';
import { getLikeCountByPostId } from './api';
function Post({ post, currentUsername }) {
const { data: likeCount } = useQuery({
queryKey: ['likeCount', post.id],
queryFn: () => getLikeCountByPostId(post.id),
});
const { data: isPostLikedByCurrentUser } = useQuery({
queryKey: ['likeStatus', post.id, currentUsername],
queryFn: () => getLikeStatusByUsername(post.id, currentUsername),
enabled: !!currentUsername,
});
return (
<li>
<div>{post.user.name}: {post.content}</div>
<button>
{isPostLikedByCurrentUser ? '♥️ ' : '♡ '}
{`좋아요 ${likeCount ?? 0}개`}
</button>
</li>
);
}
export default Post;
이제 useMutation()
을 활용해 좋아요 기능을 옵티미스틱 업데이트로 구현해보겠다.
useMutation()의 onMutate, onError, onSettled를 활용해서 구현할 수 있다.
뮤테이션 함수에는 다음과 같이 유저가 like를 했는지 unlike를 했는지에 따라 각각에 맞는 API 함수를 불러 주는 함수를 정의해준다.
import { getLikeCountByPostId, getLikeStatusByUsername, likePost, unlikePost } from './api';
// ...
const likesMutation = useMutation({
mutationFn: async ({ postId, username, userAction }) => {
if (userAction === 'LIKE_POST') {
await likePost(postId, username);
} else {
await unlikePost(postId, username);
}
},
});
그리고 onMutate 옵션을 추가해 준다. onMutate는 뮤테이션 함수가 실행되기 바로 전에 실행하는 함수이다.
여기서 우선 좋아요 데이터를 refetch하는 것을 막기 위해 cancelQueries()를 실행해서 좋아요 데이터를 받아오는 쿼리가 실행 중이라면 취소해 주도록 한다. (데이터가 refetch되어서 좋아요를 누른 결과를 덮어 쓰는 걸 방지하기 위해서) 이때, 현재 좋아요 상태와 좋아요 개수에 대한 데이터를 둘 다 변경할 예정이므로, 각각의 데이터에 대한 쿼리를 취소해 준다.
const queryClient = useQueryClient();
// ...
const likesMutation = useMutation({
mutationFn: ...
onMutate: async ({ postId, username, userAction }) => {
await queryClient.cancelQueries({ queryKey: ['likeStatus', postId, username] });
await queryClient.cancelQueries({ queryKey: ['likeCount', postId] });
},
});
현재의 좋아요에 대한 쿼리 데이터를 가져와서 유저의 액션에 따라 해당 데이터를 수정한다. 그전에 기존의 쿼리 데이터도 따로 저장해 줄건데, 이는 뮤테이션 실행 중 에러가 발생하면 이전의 데이터로 롤백하기 위해서이다.
const queryClient = useQueryClient();
// ...
const likesMutation = useMutation({
mutationFn: ...,
onMutate: async ({ postId, username, userAction }) => {
await queryClient.cancelQueries({
queryKey: ['likeStatus', postId, username],
});
await queryClient.cancelQueries({ queryKey: ['likeCount', postId] });
const prevLikeStatus = queryClient.getQueryData([
'likeStatus',
postId,
username,
]);
const prevLikeCount = queryClient.getQueryData(['likeCount', postId]);
queryClient.setQueryData(
['likeStatus', postId, username],
() => userAction === 'LIKE_POST'
);
queryClient.setQueryData(['likeCount', postId], (prev) =>
userAction === 'LIKE_POST' ? prev + 1 : prev - 1
);
},
});
마지막으로 수정하기 전의 데이터를 리턴해 준다. 앞서 설명했듯이 만약 뮤테이션 중에 에러가 발생하면 이전의 데이터로 롤백할 때 사용한다.
const queryClient = useQueryClient();
// ...
const likesMutation = useMutation({
mutationFn: ...,
onMutate: async ({ postId, username, userAction }) => {
await queryClient.cancelQueries({
queryKey: ['likeStatus', postId, username],
});
await queryClient.cancelQueries({ queryKey: ['likeCount', postId] });
const prevLikeStatus = queryClient.getQueryData([
'likeStatus',
postId,
username,
]);
const prevLikeCount = queryClient.getQueryData(['likeCount', postId]);
queryClient.setQueryData(
['likeStatus', postId, username],
() => userAction === 'LIKE_POST'
);
queryClient.setQueryData(['likeCount', postId], (prev) =>
userAction === 'LIKE_POST' ? prev + 1 : prev - 1
);
return { prevLikeStatus, prevLikeCount };
},
});
이제 에러가 발생하면 이전의 데이터로 롤백하는 부분을 onError에 추가해 준다. onError에서는 세 번째 파라미터로 context를 받아오는데, 이 context에 우리가 onMutate에서 리턴한 데이터가 들어 있다. 이걸로 해당 포스트의 좋아요 데이터를 이전 데이터로 복원할 수 있다.
const likesMutation = useMutation({
mutationFn: ...,
onMutate: async ({ postId, username, userAction }) => {
// ...
return { prevLikeStatus, prevLikeCount };
},
onError: (err, { postId, username }, context) => {
queryClient.setQueryData(
['likeStatus', postId, username],
context.prevLikeStatus
);
queryClient.setQueryData(['likeCount', postId], context.prevLikeCount);
},
});
마지막으로 제대로 된 서버 데이터로 동기화하기 위해 성공과 실패 여부에 상관없이 invalidateQueries()
함수로 데이터를 refetch하도록 하겠다.
참고로 onSettled는 성공, 실패 여부에 상관없이 항상 실행
const likesMutation = useMutation({
mutationFn: ...,
onMutate: ... ,
onError: ...,
onSettled: (data, err, { postId, username }) => {
queryClient.invalidateQueries({
queryKey: ['likeStatus', postId, username],
});
queryClient.invalidateQueries({
queryKey: ['likeCount', postId],
});
},
});
마지막으로 좋아요 버튼을 누르면 likesMutation을 실행하도록 다음과 같이 코드를 추가
const handleLikeButtonClick = (userAction) => {
if (!currentUsername) return; //로그인이 되어 있지 않으면 뮤테이션을 실행하지 않게 리턴한다.
likesMutation.mutate({
postId: post.id,
username: currentUsername,
userAction,
});
};
return (
<li>
<div>{post.user.name}: {post.content}</div>
<button
onClick={() =>
handleLikeButtonClick(
isPostLikedByCurrentUser ? 'UNLIKE_POST' : 'LIKE_POST'
)
}
>
{isPostLikedByCurrentUser ? '♥️ ' : '♡ '}
{`좋아요 ${likeCount ?? 0}개`}
</button>
</li>
);
최종 코드
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
getLikeCountByPostId,
getLikeStatusByUsername,
likePost,
unlikePost,
} from './api';
function Post({ post, currentUsername }) {
const queryClient = useQueryClient();
const { data: likeCount } = useQuery({
queryKey: ['likeCount', post.id],
queryFn: () => getLikeCountByPostId(post.id),
});
const { data: isPostLikedByCurrentUser } = useQuery({
queryKey: ['likeStatus', post.id, currentUsername],
queryFn: () => getLikeStatusByUsername(post.id, currentUsername),
enabled: !!currentUsername,
});
const likesMutation = useMutation({
mutationFn: async ({ postId, username, userAction }) => {
if (userAction === 'LIKE_POST') {
await likePost(postId, username);
} else {
await unlikePost(postId, username);
}
},
onMutate: async ({ postId, username, userAction }) => {
await queryClient.cancelQueries({
queryKey: ['likeStatus', postId, username],
});
await queryClient.cancelQueries({ queryKey: ['likeCount', postId] });
const prevLikeStatus = queryClient.getQueryData([
'likeStatus',
postId,
username,
]);
const prevLikeCount = queryClient.getQueryData(['likeCount', postId]);
queryClient.setQueryData(
['likeStatus', postId, username],
() => userAction === 'LIKE_POST'
);
queryClient.setQueryData(['likeCount', postId], (prev) =>
userAction === 'LIKE_POST' ? prev + 1 : prev - 1
);
return { prevLikeStatus, prevLikeCount };
},
onError: (err, { postId, username }, context) => {
queryClient.setQueryData(
['likeStatus', postId, username],
context.prevLikeStatus
);
queryClient.setQueryData(['likeCount', postId], context.prevLikeCount);
},
onSettled: (data, err, { postId, username }) => {
queryClient.invalidateQueries({
queryKey: ['likeStatus', postId, username],
});
queryClient.invalidateQueries({
queryKey: ['likeCount', postId],
});
},
});
const handleLikeButtonClick = (userAction) => {
console.log('@@@here', currentUsername);
if (!currentUsername) return;
likesMutation.mutate({
postId: post.id,
username: currentUsername,
userAction,
});
};
return (
<li>
<div>
{post.user.name}: {post.content}
</div>
<button
onClick={() =>
handleLikeButtonClick(
isPostLikedByCurrentUser ? 'UNLIKE_POST' : 'LIKE_POST'
)
}
>
{isPostLikedByCurrentUser ? '♥️ ' : '♡ '}
{`좋아요 ${likeCount ?? 0}개`}
</button>
</li>
);
}
export default Post;