리액트 쿼리는 데이터를 가져오고 변형하는 데 매우 유용한 다양한 훅을 제공한다. 각 훅은 특정한 역할을 수행하며, 이들을 잘 활용하면 비동기 데이터 관리를 쉽게 할 수 있다. 아래에서 주요 훅들을 소개한다.
import { useQuery } from 'react-query';
import axios from 'axios';
//api 패칭 함수
const fetchTodo = async () => {
const { data } = await axios.get('/api/todo');
return data;
};
//컴포넌트
const TodoComponent = () => {
const { data, error, isLoading } = useQuery('todo', fetchTodo);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{data.title}</div>;
};
//api.ts
//characterId, page, size를 인자로 받는다.
export async function getCharacterComments(characterId: string, page: number, size: number) {
return handleAxiosError<CharacterCommentResult>(
axios
.get(`/api/v1/character/comment/${characterId}`, {
params: { page, size },
})
.then((res) => res.data)
);
}
//CharacterComments.tsx
const PAGE_SIZE = 10;
function CharacterComments({ characterId }: { characterId: string }) {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
status,
} = useInfiniteQuery(
['characterComments', characterId],
({ pageParam = 0 }) => getCharacterComments(characterId, pageParam, PAGE_SIZE),
{
getNextPageParam: (lastPage, allPages) => {
const morePagesExist = lastPage.data.length >= PAGE_SIZE;
if (!morePagesExist) return undefined;
return allPages.length; // 다음 페이지 번호를 반환
},
}
);
/* 스크롤이 페이지 끝에 도달했을 때 데이터를 가져오도록 설정할 경우
const observerElem = useRef(null);
useEffect(() => {
if (!observerElem.current) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage) {
fetchNextPage();
}
},
{ threshold: 1.0 } // 관찰 대상이 뷰포트에 완전히 들어왔을 때 트리거
);
observer.observe(observerElem.current);
return () => observer.disconnect(); // cleanup
}, [fetchNextPage, hasNextPage]); */
if (status === 'loading') return <p>Loading...</p>;
if (status === 'error') return <p>Error loading comments.</p>;
return (
<div>
{data.pages.map((page, index) =>
page.data.map((comment: ICharacterComment) => (
<div key={`${comment.userId}-${index}`}>
<p><strong>{comment.userNickname}:</strong> {comment.comment}</p>
</div>
))
)}
<button
onClick={() => fetchNextPage()}
disabled={!hasNextPage || isFetchingNextPage}
>
{isFetchingNextPage
? 'Loading more...'
: hasNextPage
? 'Load More'
: 'No more comments'}
</button>
/* 스크롤이 페이지 끝에 도달했을 때 데이터를 가져오도록 설정할 경우
<div ref={observerElem} style={{ height: '1px' }} />
{isFetchingNextPage && <p>Loading more...</p>}
</div>/*
);
}
- getNextPageParam: 다음 페이지가 있는지 여부를 결정하고, 다음 페이지를 가져오기 위해 사용되는 페이지 번호를 반환
getNextPageParam: (lastPage, allPages) => { // lastPage에서 nextPage를 가져온다. const nextPage = lastPage.nextPage; // nextPage가 있으면 그 값을 반환하고, 없으면 undefined를 반환하여 페이지네이션을 멈춘다. return nextPage ? nextPage : undefined; }
- getNextPageParam의 인자
- lastPage:
가장 최근에 가져온 페이지의 데이터
이 데이터에는 페이지와 관련된 정보(예: 현재 페이지 번호, 다음 페이지 여부 등)가 포함되어 있을 수 있다.- allPages:
지금까지 가져온 모든 페이지의 배열
이를 통해 여러 페이지를 참고하여 다음 페이지를 결정할 수 있다.- getNextPageParam의 반환값
- 다음 페이지가 있는 경우:
다음 페이지를 가져오기 위한 파라미터를 반환
이 파라미터는 fetchFunction에 전달- 다음 페이지가 없는 경우:
undefined를 반환하여 더 이상 페이지를 가져오지 않도록 한다.
useInfiniteQuery 훅을 사용할 때 반환되는 데이터 객체에는 pages와 pageParams라는 두 가지 주요 속성이 포함된다.
낙관적 업데이트를 할 때 주의하자
아래 캡처본은 현재 3번째 페이지까지 불러온 상황이다.
- pages
- useInfiniteQuery 훅이 성공적으로 가져온 각 페이지의 데이터 배열을 포함
- 이 배열은 각 페이지의 응답 데이터를 순서대로 포함하며, useInfiniteQuery가 가져온 모든 페이지의 데이터를 추적
예를 들어, 만약 3개의 페이지를 가져왔다면, pages는 다음과 같이 구성될 수 있다.
pages: [ // 첫 번째 페이지의 데이터 [{ id: 1, name: "Item 1" }, { id: 2, name: "Item 2" }], // 두 번째 페이지의 데이터 [{ id: 3, name: "Item 3" }, { id: 4, name: "Item 4" }], // 세 번째 페이지의 데이터 [{ id: 5, name: "Item 5" }, { id: 6, name: "Item 6" }] ]
- pageParams
- useInfiniteQuery가 각 페이지의 데이터를 가져올 때 사용한 매개변수(pageParam)를 저장하는 배열
- 이 배열에는 useInfiniteQuery가 getNextPageParam 함수에 의해 반환된 각 페이지의 매개변수가 순서대로 저장
예를 들어, 다음 페이지를 가져오기 위해 getNextPageParam에서 각 페이지마다 다른 pageParam 값을 반환했다면, pageParams는 다음과 같이 구성될 수 있다.
pageParams: [ 0, // 첫 번째 페이지의 pageParam 1, // 두 번째 페이지의 pageParam 2 // 세 번째 페이지의 pageParam ]
개별 useQuery들의 타입을 배열로 묶어놓은 것이다.
여러 개의 쿼리를 동시에 실행할 때 사용된다.
사용 예:
import { useQueries } from 'react-query';
import axios from 'axios';
const fetchUser = async (userId) => {
const { data } = await axios.get(`/api/user/${userId}`);
return data;
};
const UsersComponent = ({ userIds }) => {
const results = useQueries(
userIds.map(userId => ({
queryKey: ['user', userId],
queryFn: () => fetchUser(userId),
}))
);
return (
<div>
{results.map(({ data, isLoading, error }, index) => (
<div key={userIds[index]}>
{isLoading ? (
'Loading...'
) : error ? (
`Error: ${error.message}`
) : (
data.name
)}
</div>
))}
</div>
);
};
//구조분해 할당 예시
const [boardDetailQuery, boardLikesQuery, boardCommentQuery] = useQueries([
{
queryKey: ["boardDetail", boardId],
queryFn: () => getBoardDetail(boardId + ""),
},
{
queryKey: ["boardLikes", +boardId!, user?.id],
queryFn: () => getBoardLikes(+boardId!, String(user?.id)),
},
{
queryKey: ["boardComment", boardId],
queryFn: () => getBoardComment(boardId + ""),
},
]);
// 각 쿼리의 데이터와 로딩 상태를 구조 분해 할당으로 추출
const { data: postData, isLoading: postLoading } = boardDetailQuery;
const { data: likeData, isLoading: likeLoading } = boardLikesQuery;
const { data: commentData, isLoading: commentLoading } = boardCommentQuery;
import { useMutation, useQueryClient } from 'react-query';
import axios from 'axios';
const addTodo = async (newTodo) => {
const { data } = await axios.post('/api/todo', newTodo);
return data;
};
const AddTodoComponent = () => {
const queryClient = useQueryClient();
const mutation = useMutation(addTodo, {
onSuccess: () => { // onSuccess: (data, variables) 이런 식으로 각각 응답받는 데이터, muate에 전달한 데이터를 사용할 수도 있다.
// 데이터를 새로고침
queryClient.invalidateQueries('todos');
},
onError: (error) => {
consol.log(error)//요청에 실패할 경우 error 객체가 응답 데이터가 된다.
},
});
const handleAddTodo = () => {
mutation.mutate({ title: 'New Todo' });
};
return (
<div>
<button onClick={handleAddTodo}>Add Todo</button>
{mutation.isLoading && <div>Adding todo...</div>}
{mutation.error && <div>Error adding todo: {mutation.error.message}</div>}
</div>
);
};
주의사항
useInfiniteQuery(무한 스크롤)는 pages와 pageParams라는 두 가지 주요 속성이 포함되기에 낙관적 업데이트에 주의해야 한다.
useInfiniteQuery에 적용한 예시
좋아요 예시
const toggleLike = async (postId) => {
// 서버에 좋아요 상태를 토글하는 요청
await axios.post(`/api/posts/${postId}/toggleLike`);
};
const PostComponent = ({ postId, initialLikes }) => {
const queryClient = useQueryClient();
// 좋아요 상태를 업데이트하는 뮤테이션 설정
const mutation = useMutation({
mutationFn: () => toggleLike(postId),
onMutate: async (postId) => {
// 기존 캐시 취소, '쿼리 키'로 진행 중인 refetch 취소하여 낙관적 업데이트를 덮어쓰지 않도록 함
await queryClient.cancelQueries(['liked', postId]);
// 이전 캐시 상태 가져오기
const previousLikes = queryClient.getQueryData(['liked', postId]);
// 캐시된 데이터를 낙관적 업데이트
queryClient.setQueryData(['liked', postId], (old) => ({
...old,
likes: old.likes + 1, // 낙관적으로 좋아요 수 증가
}));
// 오류 발생 시 되돌리기 위해 이전 상태 반환
return { previousLikes };
},
onError: (err, postId, context) => {
// 오류 발생 시 이전 상태로 복원
queryClient.setQueryData(['liked', postId], context.previousLikes);
},
onSettled: (postId) => {
// 성공, 실패 여부에 관계 없이 refetch(쿼리 무효화)
queryClient.invalidateQueries(['liked', postId]);
},
});
return (
<button
onClick={() => {
mutation.mutate(postId);
}}
>
좋아요
</button>
);
};
export default PostComponent;
쿼리 클라이언트를 사용하여 쿼리 캐시를 수동으로 조작할 때 사용된다.
사용 예:
import { useQueryClient } from 'react-query';
const Component = () => {
const queryClient = useQueryClient();
const handleRefetch = () => {
queryClient.invalidateQueries('todos');
};
return <button onClick={handleRefetch}>Refetch Todos</button>;
};
invalidateQueries
특정 쿼리를 무효화하여 데이터를 새로 가져올 수 있다.(코드에서 'todos'는 쿼 리 키)
removeQueries
쿼리를 캐시에서 완전히 제거(로그아웃 등 특정 데이터가 더 이상 필요하지 않을 때)
queryClient.removeQueries("member");
현재 활성화된 모든 쿼리의 페칭 상태를 확인할 수 있다.
사용 예:
import { useIsFetching } from 'react-query';
const GlobalLoadingIndicator = () => {
const isFetching = useIsFetching();
return isFetching ? <div>Loading...</div> : null;
};
리액트 쿼리는 이처럼 다양한 훅을 제공하여 데이터 페칭과 변형 작업을 효율적으로 관리할 수 있게 도와준다. 각 훅의 용도와 사용법을 잘 이해하고 적절히 활용하면, 비동기 데이터 관리가 훨씬 간편해질 것이다.