사용자에게 빠른 피드백과 서버 응답을 기다리는 시간을 최소화 해야하는 상황에 많이 사용하는 것으로 알고 있습니다.
주 사용은
좋아요, 북마크, 팔로우, 댓글, 리뷰등 매끄러운 사용자 경험을 제공하기에 효과적으로 사용할 수 있습니다.
저의 경우는
"하루공감" 프로젝트 중 게시글 좋아요 하는 부분이 있었습니다.
Supabase를 사용하였지고 조금 늦은 서버 응답이 있어서 사용자에게 빠른 피드백을 주자! 라는 생각에 구현을 하게되었습니다.
처음에는 개발 당시에는 `훅으로 빼지도 않고 실패에 대한 코드도 없이
구현을 했습니다.
const [isLike, setIsLike] = useState(post.liked_by_user);
const [likeCount, setLikeCount] = useState(post.likes_count);
const likeMutation = useMutation({
mutationFn: async () => {
setIsLike(!isLike);
isLike
? setLikeCount((prev) => prev - 1)
: setLikeCount((prev) => prev + 1);
await toggleLike(session?.user?.id, post.id, isLike);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["post"],
});
},
});
// button 태그
<button
disabled={likeMutation.isPending || !session}
className="flex items-center gap-1"
onClick={() => likeMutation.mutate()}
>
<Heart
className={`w-[16px] h-[16px] ${isLike && "fill-red-500 stroke-red-500"}`}
/>
{likeCount > 0 && (
<p
className={`text-gray-600 text-[16px] leading-[14px] ${isLike && "!text-red-500"}`}
>
{Number(likeCount).toString()}
</p>
)}
</button>
처음 로직에서는 한 파일 안에 기능 부분과 View 부분을 같이 두게 코드를 작성했습니다. likeMutation.mutate()
함수를 실행하면, state 상태값으로 화면에 보여주고, 해당 쿼리가 성공하면, invalidateQueries
를 호출하여 캐시된 데이터를 무효화하고 최신 데이터를 서버에서 다시 불러오도록 하였습니다.
<ToggleLikeButton
userId={session?.user?.id}
postId={post.id}
currentLikeStatus={post.liked_by_user}
likeCount={post.likes_count}
/>
const { mutate: toggleLike, isPending } = useToggleLike({
userId,
postId,
currentLikeStatus,
});
return (
<button
disabled={isPending}
className="flex items-center gap-1"
onClick={() => toggleLike()}
>
<Heart
className={`w-[16px] h-[16px] ${
currentLikeStatus && "fill-red-500 stroke-red-500"
}`}
/>
{likeCount > 0 && (
<p
className={`text-gray-600 text-[16px] leading-[14px] ${
currentLikeStatus && "!text-red-500"
}`}
>
{Number(likeCount).toString()}
</p>
)}
</button>
);
}
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { toggleLike } from "actions/postActions";
import { PostProps } from "components/Home/HomeSection/HomeItem";
type useToggleLikeProps = {
userId: string;
postId: number;
currentLikeStatus: boolean;
};
export const useToggleLike = ({
userId,
postId,
currentLikeStatus,
}: useToggleLikeProps) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => {
return toggleLike(userId, postId, currentLikeStatus);
},
onMutate: async () => {
await queryClient.cancelQueries({
queryKey: ["post", postId],
});
const previousPosts: PostProps = queryClient.getQueryData(["post"]);
queryClient.setQueryData(["post"], (oldPosts: PostProps[]) => {
return oldPosts.map((post: PostProps) =>
post.id === postId
? {
...post,
likes_count: post.likes_count + (currentLikeStatus ? -1 : 1),
liked_by_user: !currentLikeStatus,
}
: post
);
});
return { previousPosts };
},
onError: (error, variables, context) => {
console.error("Error toggling like:", error);
queryClient.setQueryData(["post", postId], context.previousPosts);
},
onSettled: () => {
queryClient.invalidateQueries({
queryKey: ["post", postId],
});
},
});
};
해당 로직을 보면
toggleLike 함수를 호출하는 변이(mutation)를 설정합니다. toggleLike는 userId, postId, currentLikeStatus 인수를 받아서 서버에서 좋아요 상태를 토글하도록 합니다.
낙관적 업데이트 하기
좋아요 버튼을 누르면, 서버에 응답을 받기전 UI를 업데이트를 합니다.
현재 게시물의 likes_count 와 liked_by_user 상태를 변경하는데, likes_count는 좋아요 상태에 따라 증가하거나 감소하고, liked_by_user는 토글 역활을 하게 됩니다.
서버 요청 중 오류가 발생하면, onMutate 에서 정의한, previousPosts 를 onError 의 3번째 인자, context로 접근이 가능합니다. 해서 이전값을 다시 세팅해 줍니다.
onSettled 같은 경우, 성공 여부에 관계없이 항상 실행됩니다. 따라서 마지막에 invalidateQueries 가 실행되어 다음과 같은 이점이 존재합니다.
이렇게 작성함으로서 기존 코드에서 좋아요를 여러번 눌렀을 때
타이밍 이슈가 생겨 데이터가 일관적이지 못하는 상황도 해결할 수 있었고 컴포넌트 분리하여 각 컴포넌트 간의 책임을 명확하게 한 점도 개선 전보다 좋아졌다고 생각합니다.
이상으로 여기까지 마치겠습니다!