최종 프로젝트 - Optimistic update로 찜 기능 구현
찜 / 찜 취소 기능 구현
트리거를 연결해준 덕에 그냥 상품좋아요 테이블에 행 넣기만 하면 끝
useMutation으로 optimistic update를 적용시킬 거다.
optimistic update란 쉽게 말해 아님말고식 업데이트
좋아요로 예를 들면 좋아요를 누르지 않은 상태에서 좋아요 버튼 클릭하면 일단 좋아요 누른 것 처럼 보이게하고
서버에 요청을 보낸다.
요청에 성공해 서버와 클라이언트의 상태가 같으면 (둘 다 좋아요한 상태) 그대로 두고
다르다면 (클라이언트는 좋아요한 상태인데 서버에서는 에러가 발생해 좋아요가 반영되지 않음)
다시 원래 상태로 돌려놓는 => 아님말고
일단 유저의 상품에 대한 좋아요 여부부터 판단하기
라우트 핸들러
/api/goods/[goods_id]/like/route.ts
나는 상품별로 좋아요를 유저가 눌렀는지를 확인하기 위해 위와 같이 경로 설정했고
유저정보는 쿼리스트링으로 받아오려했다.
export const GET = async (request: Request, { params }: ParamsType) => {
const { goods_id } = params;
const { searchParams } = new URL(request.url);
const user_id = searchParams.get('user_id');
if (!user_id) return NextResponse.json({ ereor: '로그인 해주세요.' });
const { data, error } = await supabase
.from('liked_goods')
.select('*')
.match({ goods_id, user_id });
if (error) return NextResponse.json({ error });
return NextResponse.json(!!data.length);
};
처음에는 행 전체를 가져왔다가 optimistic update를 적용시키려면 불린타입으로 가져오는게 더 좋겠다고 생각했다.
Supabase는 해당 데이터가 없으면 빈 배열로 반환해서 length 속성을 이용했다.
서비스 함수
export const getIsLikedGoodsByUser = async (
goods_id: string,
user_id: string,
) => {
const response = await axios.get(
`/api/goods/${goods_id}/like?user_id=${user_id}`,
);
return response.data;
};
유저별 해당 상품에 대한 좋아요여부를 판단하는 서비스 함수
유저 정보를 쿼리스트링으로 보내준다.
커스텀 훅
const useGetIsLikedGoodsByUser = (goods_id: string, user_id: string) => {
return useQuery<boolean>({
queryKey: ['like', goods_id, user_id],
queryFn: () => getIsLikedGoodsByUser(goods_id, user_id),
});
};
굿즈별로 유저별로 좋아요여부를 확인하기위해 쿼리키를 저렇게 설정했다.
Hearts 컴포넌트 (좋아요 컴포넌트)
// Hearts.tsx
...
const {
data: isLiked,
isError,
isPending,
} = useGetIsLikedGoodsByUser(goods_id, user_id);
...
{isLiked ? <HeartPressedIcon32px /> : <HeartDefaultIcon32px />}
isLike가 true라면 좋아요 버튼이 꽉 찬 하트가 되고 클릭시 좋아요가 취소되도록
false라면 빈 하트 버튼에 클릭하면 좋아요가 되도록 할 거다.
좋아요 여부에 따른 찜 / 찜 취소 기능
라우트 핸들러
// 찜 (굿즈좋아요 테이블에 행 추가)
export const POST = async (request: Request, { params }: ParamsType) => {
const { goods_id } = params;
const { searchParams } = new URL(request.url);
const user_id = searchParams.get('user_id');
if (!user_id)
return NextResponse.json({ error: '유저id를 받지 못했습니다.' });
const { data, error } = await supabase.from('liked_goods').insert({
goods_id,
user_id,
});
if (error) return NextResponse.json({ error });
return NextResponse.json(data);
};
// 찜 취소 (굿즈좋아요 테이블에 행 삭제)
export const DELETE = async (request: Request, { params }: ParamsType) => {
const { goods_id } = params;
const { searchParams } = new URL(request.url);
const user_id = searchParams.get('user_id');
if (!user_id)
return NextResponse.json({ error: '유저 id를 받지 못했습니다.' });
const { data, error } = await supabase
.from('liked_goods')
.delete()
.match({ goods_id, user_id });
if (error) return NextResponse.json({ error });
return NextResponse.json(data);
};
서비스 함수
export const toggleLikeGoods = async (
toggleLikeGoodsParams: toggleLikeGoodsParamsType,
) => {
const { goods_id, user_id, isLiked } = toggleLikeGoodsParams;
// 찜한 상태라면 DELETE 요청
if (isLiked) {
const response = await axios.delete(
`/api/goods/${goods_id}/like?user_id=${user_id}`,
);
console.log(response);
return response;
} else { // 그게 아니라면 POST 요청
const response = await axios.post(
`/api/goods/${goods_id}/like?user_id=${user_id}`,
);
console.log(response);
return response;
}
};
커스텀 훅 (useMutation, Optimistic update)
export const useToggleLikeGoods = (
goods_id: string,
user_id: string,
isLiked: boolean,
) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: toggleLikeGoods,
onMutate: async ({ goods_id, user_id, isLiked }) => {
await queryClient.cancelQueries({
queryKey: ['like', goods_id, user_id],
});
const previousHeart = queryClient.getQueryData([
'like',
goods_id,
user_id,
]);
queryClient.setQueriesData(
{ queryKey: ['like', goods_id, user_id] },
!isLiked,
);
return { previousState: previousHeart };
},
onError: (err, isLiked, context) => {
queryClient.setQueriesData(
{ queryKey: ['like', goods_id, user_id] },
context?.previousState,
);
},
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['like', goods_id, user_id] });
},
});
};
1. onMutate
onMutate: async ({ goods_id, user_id, isLiked }) => {
await queryClient.cancelQueries({
queryKey: ['like', goods_id, user_id],
});
const previousHeart = queryClient.getQueryData([
'like',
goods_id,
user_id,
]);
queryClient.setQueriesData(
{ queryKey: ['like', goods_id, user_id] },
!isLiked,
);
return { previousState: previousHeart };
},
일단 기존 좋아요 상태에서 반대로 적용시키고본다.
찜 X => 찜O, 찜 O => 찜 X
그리고 이전의 상태를 previousState에 저장해둔다.
2. mutateFn
mutationFn: toggleLikeGoods,
toggleLikeGoods 서비스 함수를 이용해 서버에 요청을 보낸다.
3 onSettled
onSettled: () => {
queryClient.invalidateQueries({ queryKey: ['like', goods_id, user_id] });
},
서버에서 성공했다는 응답을 보내면 기존 쿼리 무효화 시킨다.
4. onError
onError: (err, isLiked, context) => {
queryClient.setQueriesData(
{ queryKey: ['like', goods_id, user_id] },
context?.previousState,
);
},
망했다면 원래의 상태를 저장해뒀던 previousState로 돌려놓는다.
이제 Hearts 컴포넌트에 적용
...
const { mutate: likeMutate } = useToggleLikeGoods(
goods_id,
user_id,
isLiked!,
);
const handleToggleLike = () => {
if (isLiked === undefined) return;
const toggleParams: toggleLikeGoodsParamsType = {
goods_id,
user_id,
isLiked,
};
likeMutate(toggleParams);
};
...
좋아요 버튼을 클릭하면 mutate가 실행된다.
결과
빈 하트를 누르면 일단 하트를 꽉 채우고보고 요청을 보낸다.
처음으로 optimistic update를 적용시켜봤는데 생각한대로 잘 된다.
굿