
📅 2025-11-20
➡️ TanStack Query 낙관적 업데이트, 무한 스크롤에 대해 새롭게 알게 된 것 또는 헷갈리는 부분 정리
🔗 https://tanstack.com/query/latest/docs/framework/react/guides/query-cancellation
QueryFunctionContext에서 signal 사용하기
queryFn은 기본적으로 QueryFunctionContext 객체를 인자로 받음queryKey: 쿼리의 고유 키
pageParam: useInfiniteQuery에서 사용되는 페이지 매개변수
signal: 요청 취소를 위한 AbortSignal
meta: 부가적인 메모 정보
export const getTodos = async (context) => {
const { queryKey, pageParam, signal, meta } = context;
const response = await axios.get("http://localhost:5000/todos", { signal });
return response.data;
};
useQuery({
queryKey: ["todos"],
queryFn: getTodos,
});
signal을 axios 또는 fetch 요청에 전달하면, 쿼리가 취소될 때 해당 요청도 함께 중단됨
컴포넌트 언마운트 시 자동 취소
useQuery({
queryKey: ["todos"],
queryFn: ({ signal }) => axios.get("/todos", { signal }),
});
수동으로 쿼리 취소하기
const queryClient = useQueryClient();
const query = useQuery({
queryKey: ["todos"],
queryFn: ({ signal }) => fetch("/todos", { signal }).then((res) => res.json()),
});
<button onClick={() => queryClient.cancelQueries({ queryKey: ["todos"] })}>
Cancel
</button>;
사용 시 주의 사항
🔗 https://tanstack.com/query/latest/docs/framework/react/guides/optimistic-updates
UI 반응 속도가 빠름
실패 처리를 반드시 고려해야 함
서버와 클라이언트 상태가 일시적으로 불일치할 수 있음
예시
const { mutate } = useMutation({
mutationFn: addTodo,
onMutate: async (newTodo) => {
// todos 쿼리로 진행 중인 요청이 있다면 취소
// 낙관적 업데이트 할 떄 중간에 들어오는 응답이 캐시를 덮어버리는 현상 방지
await queryClient.cancelQueries({ queryKey: ['todos'] });
// 현재 캐시에 들어있는 todo를 가지고 오고, 나중에 에러가 나게 되면 롤백을 하기 위함
const preTodos = queryClient.getQueryData(['todos']);
// 서버 응답을 기다리지 않고, 바로 캐시에 newTodo 추가 -> 즉시 화면에 업데이트 됨 => 낙관적 업데이트
queryClient.setQueryData(['todos'], (old) => [...old, newTodo]);
return { preTodos }; // context 객체 반환
},
onSuccess: () => {
queryClient.invalidateQueries(['todos']); // refetching
setTodo('');
},
// mutation이 실패하면 onMutate에서 반환된 context를 사용하여 롤백 진행
onError: (error, newTodo, context) => {
queryClient.setQueryData(['todos'], context.preTodos);
},
onSettled: () => {
// 성공 또는 실패 이후 공통적으로 실행되는 곳
// todos 쿼리를 무효화 -> refetch 진행해서 서버와 캐시의 동기화를 유도
queryClient.invalidateQueries({ queryKey: ['todos'] });
},
});
🔗 https://tanstack.com/query/latest/docs/framework/react/guides/prefetching
queryClient.prefetchQuery({
queryKey: ["todos"],
queryFn: fetchTodos,
});
🔗 https://tanstack.com/query/latest/docs/framework/react/guides/paginated-queries
v4 방식 (keepPreviousData)
useQuery({
queryKey: ["todos", page],
queryFn: () => fetchTodos(page),
keepPreviousData: true, // 이전 데이터 유지
});
isPreviousData로 이전 페이지인지 확인 가능v5 방식 (placeholderData, isPlaceholderData)
keepPreviousData와 isPreviousData 제거됨
→ 대신 placeholderData + isPlaceholderData 플래그 활용
이전 페이지 데이터를 그대로 활용하면서 같은 UX 구현 가능
import { useQuery } from "@tanstack/react-query";
const { data } = useQuery({
queryKey: ["super-heroes", page],
queryFn: getAllSuperHero,
placeholderData: (previousData, previousQuery) => previousData,
});
placeholderData는 캐시가 없는 경우 보여줄 초기 데이터를 정의
이전 페이지 데이터를 그대로 반환하면, v4의 keepPreviousData: true와 동일한 효과
필요에 따라 isPlaceholderData를 확인해서 UI에 표시할지 여부 결정 가능
🔗 https://tanstack.com/query/latest/docs/framework/react/guides/infinite-queries
🔗 https://tanstack.com/query/v5/docs/framework/react/reference/useInfiniteQuery
무한 스크롤이나 load more(더 보기))useQuery의 유용한 버전인 useInfiniteQuery을 지원useInfiniteQuery({
queryKey: ["projects"],
queryFn: fetchProjects,
getNextPageParam: (lastPage) => lastPage.nextCursor,
});getNextPageParam을 통해 다음 페이지 여부를 결정lastPage는 fetch 해온 가장 최근에 가져온 페이지 목록pages 배열 형태로 관리됨scroll)를 수동으로 추적하면 계속 이벤트가 쏟아지기 때문에 성능 문제가 생기기 쉬움useInView 훅을 제공하는 react-intersection-observer 라이브러리를 사용 🔗 https://www.npmjs.com/package/react-intersection-observernpm i react-intersection-observerimport { fetchTmdbData } from '../api/tmdb';
import { useInView } from 'react-intersection-observer';
import { useInfiniteQuery } from '@tanstack/react-query';
const MovieLoadMore = () => {
const {
data: movieData,
hasNextPage, // 다음 페이지 호출 가능한지 여부
fetchNextPage, // 실제 다음 페이지를 불러오는 함수
isFetchingNextPage,
// 지금 현재 fetchNextPage가 실행 중인지 알려주는 상태값
// true -> 호출되고 있는 중, false -> 호출 끝
// 여러 번 트리거가 되고 있으면 어떠한 버그가 일어날지 모르니까 방어적 코드를 작성하기 위함
} = useInfiniteQuery({
queryKey: ['movies'],
queryFn: fetchTmdbData,
getNextPageParam: (lastPage) => {
console.log('lastPage: ', lastPage.total_pages);
console.log('lastPage: ', lastPage.page);
if (lastPage.page < lastPage.total_pages) {
return lastPage.page + 1;
}
},
select: (data) => {
return data.pages.flatMap((page) => page.results);
},
});
const { ref } = useInView({
threshold: 1,
onChange: (inView) => {
console.log(inView);
if (!inView || !hasNextPage || isFetchingNextPage) return;
fetchNextPage();
},
});
return (
<div style={{ padding: 0 }}>
<h2 style={{ paddingTop: '4rem' }}>영화 더보기 무한스크롤</h2>
<ul style={{ display: 'flex', flexDirection: 'column', gap: '10px', marginBottom: '200px' }}>
{movieData?.map((movie) => (
<li key={`${movie.id}`}>{movie.title}</li>
))}
</ul>
<div ref={ref} style={{ background: 'red', color: 'white', width: '100%', height: '100px' }}>
트리거
</div>
</div>
);
};
export default MovieLoadMore;