Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,21 @@
import {
useCommentMutation,
useDeleteCommentMutation,
useEditCommentMutation,
} from "@/app/api/comments/mutation";
import { useCommentListQueryObject } from "@/app/api/comments/query";
import CommentBox from "@/shared/component/CommentBox";
import CommentInput from "@/shared/component/CommentInput";
import { useQuery } from "@tanstack/react-query";
import { useSession } from "next-auth/react";
import { type FormEvent, useEffect, useRef, useState } from "react";
import {
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { commentInputStyle, sectionWrapper, ulStyle } from "./index.css";
import { CommentsProvider } from "./provider";

type CommentSectionProps = {
solutionId: number;
Expand All @@ -21,10 +27,15 @@ const CommentSection = ({ solutionId }: CommentSectionProps) => {
const commentRef = useRef<HTMLUListElement>(null);
const [comment, setComment] = useState("");
const { data: session } = useSession();

const nickname = session?.user?.nickname;
const { data: comments } = useQuery(useCommentListQueryObject(solutionId));
const { mutate: commentAction } = useCommentMutation(solutionId);
const { mutate: deleteMutate } = useDeleteCommentMutation(solutionId);
const { mutate: commentEditMutate } = useEditCommentMutation();

const onCommentEdit = useCallback((commentId: number, content: string) => {
commentEditMutate({ commentId, content });
}, []);

const handleCommentSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
Expand All @@ -44,19 +55,17 @@ const CommentSection = ({ solutionId }: CommentSectionProps) => {
return (
<div className={sectionWrapper}>
<ul className={ulStyle} ref={commentRef}>
<CommentsProvider solutionId={+solutionId}>
{comments
?.sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt))
.map((item) => (
<CommentBox
key={item.commentId}
variant="detail"
onDelete={deleteMutate}
isMine={item.writerNickname === session?.user?.nickname}
{...item}
/>
))}
</CommentsProvider>
{comments
?.sort((a, b) => +new Date(a.createdAt) - +new Date(b.createdAt))
.map((commentContent) => (
<CommentBox
key={commentContent.commentId}
commentContent={commentContent}
isMine={commentContent.writerNickname === nickname}
onDelete={deleteMutate}
onCommentEdit={onCommentEdit}
/>
))}
</ul>
<form onSubmit={handleCommentSubmit} className={commentInputStyle}>
<CommentInput
Expand Down

This file was deleted.

67 changes: 54 additions & 13 deletions apps/client/src/app/api/comments/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import { useToast } from "@/common/hook/useToast";
import { HTTP_ERROR_STATUS } from "@/shared/constant/api";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { HTTPError } from "ky";
import { useParams } from "next/navigation";
import { commentQueryKey } from "./query";
import type { CommentContent } from "./type";

export const useCommentMutation = (solutionId: number) => {
const queryClient = useQueryClient();
Expand All @@ -19,9 +21,10 @@ export const useCommentMutation = (solutionId: number) => {
await queryClient.invalidateQueries({
queryKey: commentQueryKey.all(),
});
showToast("댓글이 작성되었어요.", "success");
},
onError: () => {
showToast("댓글을 작성하는데 실패하였어요", "error");
showToast("댓글 작성에 실패했어요", "error");
},
});
};
Expand All @@ -36,6 +39,7 @@ export const useDeleteCommentMutation = (solutionId: number) => {
await queryClient.invalidateQueries({
queryKey: commentQueryKey.list(solutionId),
});
showToast("댓글이 삭제되었어요.", "success");
},
onError: (error: HTTPError) => {
if (!error.response) return;
Expand All @@ -47,26 +51,63 @@ export const useDeleteCommentMutation = (solutionId: number) => {
});
};

export const useEditCommentMutation = (
solutionId: number,
commentId: number,
) => {
export const useEditCommentMutation = () => {
const queryClient = useQueryClient();
const params = useParams();
const { showToast } = useToast();
const itemId = +params.id;

return useMutation({
mutationFn: (content: string) => editComment(commentId, content),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: commentQueryKey.list(solutionId),
});
mutationFn: ({
commentId,
content,
}: { commentId: number; content: string }) =>
editComment(commentId, content),

onMutate: async (updatedComment) => {
const queryKey = commentQueryKey.list(itemId);

await queryClient.cancelQueries({ queryKey });

const previousComments =
queryClient.getQueryData<CommentContent[]>(queryKey);

if (previousComments) {
const newComments = previousComments.map((comment) =>
comment.commentId === updatedComment.commentId
? { ...comment, content: updatedComment.content }
: comment,
);
queryClient.setQueryData(queryKey, newComments);
}

return { previousComments, queryKey };
},
onError: (error: HTTPError) => {
if (!error.response) return;

onError: (error: HTTPError, _variables, context) => {
if (context?.previousComments) {
queryClient.setQueryData(context.queryKey, context.previousComments);
}

if (!error.response) {
showToast("댓글 수정에 실패했어요.", "error");
return;
}

const { status } = error.response;
if (status === HTTP_ERROR_STATUS.BAD_REQUEST) {
showToast("댓글 작성자가 아닙니다", "error");
showToast("댓글 작성자가 아니거나, 요청이 잘못되었습니다.", "error");
} else {
showToast("댓글 수정에 실패했어요.", "error");
}
},

onSettled: () => {
queryClient.invalidateQueries({ queryKey: commentQueryKey.list(itemId) });
},

onSuccess: () => {
showToast("댓글이 수정되었어요.", "success");
},
});
};
60 changes: 46 additions & 14 deletions apps/client/src/app/api/notices/mutation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import type { NoticeRequest } from "@/app/api/notices/type";

import { useToast } from "@/common/hook/useToast";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useParams } from "next/navigation";
import type { CommentContent } from "../comments/type";
import { deleteNoticeAction, patchNoticeAction } from "./action";

export const useNoticeCommentMutation = (noticeId: number) => {
Expand All @@ -21,29 +23,59 @@ export const useNoticeCommentMutation = (noticeId: number) => {
await queryClient.invalidateQueries({
queryKey: noticeQueryKey.comments(noticeId),
});
showToast("댓글이 작성되었어요.", "success");
},
onError: () => {
showToast("댓글 작성에 실패했습니다.", "error");
showToast("댓글 작성에 실패했어요.", "error");
},
});
};

export const usePatchNoticeCommentMutation = (
noticeId: number,
commentId: number,
) => {
export const usePatchNoticeCommentMutation = () => {
const queryClient = useQueryClient();
const params = useParams();
const { showToast } = useToast();
const noticeId = +params.noticeId;

return useMutation({
mutationFn: (content: string) => patchNoticeComment(commentId, content),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: noticeQueryKey.comments(noticeId),
});
mutationFn: ({
commentId,
content,
}: { commentId: number; content: string }) =>
patchNoticeComment(commentId, content),

onMutate: async (updatedComment) => {
const queryKey = noticeQueryKey.comments(noticeId);
await queryClient.cancelQueries({ queryKey });

const previousComments =
queryClient.getQueryData<CommentContent[]>(queryKey);

if (previousComments) {
const newComments = previousComments.map((comment) =>
comment.commentId === updatedComment.commentId
? { ...comment, content: updatedComment.content }
: comment,
);
queryClient.setQueryData(queryKey, newComments);
}

return { previousComments, queryKey };
},
onError: () => {
showToast("댓글 수정에 실패했습니다.", "error");

onError: (_err, _variables, context) => {
showToast("댓글 수정에 실패했어요.", "error");
if (context?.previousComments) {
queryClient.setQueryData(context.queryKey, context.previousComments);
}
},

onSettled: (_data, _error, _variables, context) => {
queryClient.invalidateQueries({ queryKey: context?.queryKey });
},

onSuccess: () => {
showToast("댓글이 수정되었어요.", "success");
},
});
};
Expand All @@ -58,10 +90,10 @@ export const useDeleteNoticeCommentMutation = (noticeId: number) => {
await queryClient.invalidateQueries({
queryKey: noticeQueryKey.comments(noticeId),
});
showToast("댓글이 삭제되었습니다.", "success");
showToast("댓글이 삭제되었어요.", "success");
},
onError: () => {
showToast("댓글 삭제에 실패했습니다.", "error");
showToast("댓글 삭제에 실패했어요.", "error");
},
});
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ export const iconStyle = recipe({
export const listStyle = style({
width: "100%",

padding: "1.2rem 2.4rem 0 2.4rem",
padding: "1.2rem 0 0 2.4rem",
overflowY: "scroll",

...scrollTheme.scrollbar,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,11 @@ import {
useDeleteNoticeCommentMutation,
useDeleteNoticeMutation,
useNoticeCommentMutation,
usePatchNoticeCommentMutation,
usePatchNoticeMutation,
} from "@/app/api/notices/mutation";
import { useNoticeCommentListQueryObject } from "@/app/api/notices/query";
import type { NoticeContent } from "@/app/api/notices/type";
import { NoticeCommentsProvider } from "@/app/group/[groupId]/@modal/(.)notice/components/NoticeModal/NoticeDetail/provider";
import { IcnClose, IcnEdit, IcnNew } from "@/asset/svg";
import Avatar from "@/common/component/Avatar";
import Textarea from "@/common/component/Textarea";
Expand All @@ -19,7 +19,13 @@ import { useQuery } from "@tanstack/react-query";
import clsx from "clsx";
import { useSession } from "next-auth/react";
import { useRouter } from "next/navigation";
import { type FormEvent, useEffect, useRef, useState } from "react";
import {
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import {
articleStyle,
contentStyle,
Expand Down Expand Up @@ -49,6 +55,7 @@ const NoticeDetail = ({
groupId,
}: NoticeDetailProps) => {
const { data: session } = useSession();
const nickname = session?.user?.nickname;

const router = useRouter();
const handleClose = () => {
Expand Down Expand Up @@ -77,6 +84,14 @@ const NoticeDetail = ({
groupId,
noticeId,
);
const { mutate: commentEditMutate } = usePatchNoticeCommentMutation();

const handleCommentEdit = useCallback(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

여기서 useCallback을 사용하신 이유가 무엇일까요?

어차피 input에 타이핑해서 comment 상태가 변하면 CommentBox까지 리랜더링이 진행될텐데 useCallback으로 감싸서 넘겨주는 이유가 궁금합니다!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

isEditing 값을 atom을 통해 바로 가져오는 함수를 만들었다가 무한 루프를 만났는데, useMutation 관련 문제로 착각해서 이리저리 만지작댄 흔적이에요.

export const isItemEditingAtom = (itemId: number) =>  atom((get) => get(editingItemIdAtom) === itemId);

위 코드가 바로 댓글 id를 인자로 받아서 isEditing값을 바로 얻어내는 atom인데, 객체 자체를 저장하는게 아니라 함수를 반환하고 있어요. 그 결과 이걸 사용하면 매 렌더링마다 atom 객체가 반환되고, 새 객체로 바뀌었으니까 리렌더링이 되는 무한루프에 빠진거였죠.

useCallback을 쓰게 된 이유는 이렇고, 이 부분을 성능 측면에서 다시 보면 handleCommentEdit외에 deleteMutate도 감싸야 하고, comments도 처리하고 어쩌구저쩌구 하는것 보다는 아예 댓글 다는 컴포넌트를 따로 빼서 setState의 영향을 좁히는 방향으로 가는게 좋을거에요. 결국 처음 컴포넌트를 만들때 하나에 다 몰아넣어서 만든 탓에 생긴 기술부채인거죠.

(commentId: number, content: string) => {
commentEditMutate({ commentId, content });
},
[],
);

const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
Expand Down Expand Up @@ -171,22 +186,16 @@ const NoticeDetail = ({

<div className={sectionWrapper}>
<ul ref={commentListRef} className={listStyle}>
<NoticeCommentsProvider noticeId={noticeId}>
{commentList?.map((item) => (
<div key={item.commentId} className={itemStyle}>
<CommentBox
variant="notice"
commentId={item.commentId}
createdAt={item.createdAt}
content={item.content}
writerNickname={item.writerNickname}
writerProfileImage={item.writerProfileImage}
isMine={session?.user?.nickname === item.writerNickname}
onDelete={deleteCommentMutate}
/>
</div>
))}
</NoticeCommentsProvider>
{commentList?.map((commentContent) => (
<div key={commentContent.commentId} className={itemStyle}>
<CommentBox
commentContent={commentContent}
isMine={nickname === commentContent.writerNickname}
onDelete={deleteCommentMutate}
onCommentEdit={handleCommentEdit}
/>
</div>
))}
</ul>
<form onSubmit={handleSubmit} className={inputStyle}>
<CommentInput
Expand Down
Loading