타입스크립트 + useInfiniteQuery : 좋아요 기능 구현 (Optimistic Update)

728x90
반응형

⭐ 개발환경

* React (CRA)

* TypeScript

* TanStack Query v5

* Firebase / FireStore

* Devtools

 

⭐ Optimistic Update + useInfiniteQuery  사용 이유

* 사용자 경험을 향상시키기 위함

*  비동기 작업과 관련된 복잡성을 쉽게 해결 가능

*  데이터 패칭/ 데이터 캐싱/ 오류시 상태관리 가능

=> 요청된 데이터 캐시하고, 해당 데이터 변경시 자동으로 업데이트 해줌.

=> 프리패칭으로 미리 가져와서 처리하기 가능 (리팩토링시 적용 예정)

 

1) Optimistic Update 

: 사용자가 좋아요 버튼을 눌렀을때, 즉시 UI 업데이트를 하기 가능, 후에 데이터처리

 

2) useInfiniteQuery

: 한번에 모든 데이터가 아닌 일부 데이터만 불러오기 가능함

  (대량의 데이터를 처리에 유용해보임)

: 더보기, 페이지네이션, 무한 스크롤 등 패칭을 구현하는데 쉬움

 

⭐ 참고자료

 

Optimistic Updates | TanStack Query Docs

React Query provides two ways to optimistically update your UI before a mutation has completed. You can either use the onMutate option to update your cache directly, or leverage the returned variables to update your UI from the useMutation result. Via the

tanstack.com

 

 

Query Cancellation | TanStack Query Docs

TanStack Query provides each query function with an AbortSignal instance. When a query becomes out-of-date or inactive, this signal will become aborted. This means that all queries are cancellable, and you can respond to the cancellation inside your query

tanstack.com

 

Infinite Queries | TanStack Query Docs

Rendering lists that can additively "load more" data onto an existing set of data or "infinite scroll" is also a very common UI pattern. TanStack Query supports a useful version of useQuery called useInfiniteQuery for querying these types of lists. When us

tanstack.com

 

 

(1) 하트 클릭하는 버튼 

 <St.HeartClickButton
  onClick={(e) => handleClickLikeButton(e, post.id, post)}
  $isLiked={!!post.isLiked}
>
  {post.isLiked ? <GoHeartFill /> : <GoHeart />}
</St.HeartClickButton>

▶ <St.HeartClickButton> : styled-components 사용

클릭시 handleClickLikeButton : event, 아이디, 게시물 데이터 전달

$isLiked : css props (★: props로 전달시, 꼭 camelCase로 작성해야함)

!!post.isLiked : !! 사용해서 boolean으로 반환하기 (!!post.isLiked = true /false)

▶ 아이콘 : react-icons/go import해서 사용

 

 

 

(2) handleClickLikeButton 함수

  const handleClickLikeButton = async (
    e: React.MouseEvent<HTMLButtonElement, MouseEvent>,
    id: string,
    post: PostType
  ) => {
    e.preventDefault();
    e.stopPropagation();

    if (!currentUserId) {
      //모달창: 로그인 또는 좋아요 누르기 취소 

    await toggleLike({ postId: id, postData: post });
  };

e.preventDefault() / e.stopPropagation() : 이벤트 기본 동작 취소

    (새로고침 + 부모요소로 이벤트 전달 등)

if (!currentUserId) { ... } : 로그인한 경우에만 좋아요 기능 구현 가능

toggleLike({ postId: id, postData: post }) : 좋아요 토글

  : 뮤테이션은 toggleLike 함수를 호출

   (postId와 postData => 좋아요 하고 싶은 게시물의 id와 게시물 데이터)

 

 

 

(3) toggleLike : 좋아요 토글 + 좋아요 수 반영

(3-1) mutationFn

interface PostCardProps {
  postId: string;
  postData: PostType;
}

const { mutateAsync: toggleLike } = useMutation({
    mutationFn: async (params: PostCardProps) => {
      const { postId, postData } = params;
      const postRef = doc(db, 'posts', postId);

      if (postData.isLiked !== undefined && currentUserId !== undefined) {
        let newLikeCount;
        if (postData.isLiked) {
          //이미 좋아요한 경우
          newLikeCount = postData.likeCount ? postData.likeCount - 1 : 0;
        } else {
          //좋아요 안 한 경우
          newLikeCount = postData.likeCount != undefined ? postData.likeCount + 1 : 1;
        }

        await updateDoc(postRef, {
          likedUsers: postData.isLiked ? arrayRemove(currentUserId) : arrayUnion(currentUserId),
          likeCount: newLikeCount
        });
      } else {
        return;
      }
    },
    onMutate:()=>{},
    onError:()=>{},
    onSetteld:()=>{},
  });

 

mutationFn: async (params: PostCardProps)  : 비동기함수의 뮤테이션 함수

   : params라는 객체를 매개변수로 받음

    ( 해당 개체는 PostId, postData: 게시물 데이터 (PostType형식: 내가 지정함)

const postRef = doc(db, 'posts', postId);

   : Firestore에서 'posts'라는 컬렉션에서,

    특정 게시물(postId)을 Firestore 문서에 대한 참조 생성

if (postData.isLiked !== undefined && currentUserId !== undefined) { ... }

   : 좋아요 기능은 로그인한 사용자만 가능

newLikeCount : 업데이트할 좋아요 수 

    (이렇게 구분하지 않으면 계속 -1 이 되었음)
   1) 좋아요를 이미 누른 경우: 좋아요 취소 + 좋아요 수 -1

   2) 좋아요를 안 누른 경우 : 좋아요 + 좋아요 수 +1

await updateDoc(postRef, { ... }) 

  : updateDoc은 fireStore에서 문서를 업데이트 할때 사용

  :  likeCount 에는 새롭게 업데이트 한 좋아요 수 넣기  

  : arrayUnion과 arrayRemove를 사용해서 좋아요 & 좋아요 취소한 사용자 id 저장

 

배열 필드가 포함된 Firestore 문서 업데이트  |  Google Cloud

배열 필드가 포함된 Firestore 문서 업데이트

cloud.google.com

 

(3-2) onMutate

onMutate: async (params: PostCardProps) => {
      const { postId: selectedPostId } = params;
      await queryClient.cancelQueries({ queryKey: [QUERY_KEYS.POSTS, category] });

      //이전 데이터 저장
      const previousPosts = queryClient.getQueriesData<InfiniteData<PostTypeFirebase[]> | undefined>({
        queryKey: [QUERY_KEYS.POSTS, category]
      });

      queryClient.setQueryData<InfiniteData<PostTypeFirebase[]>>([QUERY_KEYS.POSTS, category], (oldData) => {
        if (!oldData) return oldData;

        const updateData = oldData?.pages.map((pagesPost) => {
          return pagesPost.map((post) => {
            const newLikeCount = post.isLiked ? post.likeCount - 1 : post.likeCount + 1;
            if (post.id === selectedPostId) {
              return {
                ...post,
                isLiked: !post.isLiked,
                likeCount: newLikeCount
              };
            } else {
              return post;
            }
          });
        });

        return {
          ...oldData,
          pages: updateData
        };
      });
      return { previousPosts };
    },

▶ 뮤테이션이 시작되기 전기 전에 호출되는 함수 = 제일 먼저 실행됨!

     (로컬 상태나 캐시를 업데이트 하는 용도로 사용)

▶queryClient.cancelQuries({쿼리키})

   : 아직 처리하지 못한 데이터 또는 overwirte 하지 않기 위해 작성

queryClient.getQueriesData 함수 : 이전 데이터 가져옴 => 백업용

▶ param에서 필요한 postId 추출 = selectedPostId  (좋아요 토글 작업할 게시물 ID)

queryClient.setQueriesData 함수 : 쿼리 업데이트

 (호출 방법)

 1) queryKey : 쿼리키 설정 (카테고리 별로, 데이터를 가져오는 구조임)

 2) (prevPosts) => {...} : 이전 쿼리 데이터를 받아서 새로운 데이터로 변환하는 로직

    * 이전 쿼리 데이터에 (최초 쿼리 수행시)

    빈 데이터 객체 반환 (★ infiniteQuery의 구조인 pages와 pageParams 중요)

 3) updatePages : 이전 데이터에서 pages 배열 가져오기

    ( ★ infiniteQuery의 구조인 pages )

     여기서 selectedPostId와 일치하는 게시물 찾아서 'isLiked' 속성 바꾸기

4) return 반환값 : 업데이트된 페이지 데이터를 포함한 새로운 객체 

    React Query 내부 메커니즘 중 하나인 캐시에 저장

    캐시된 데이터: useMutation을 통해 다른 곳에서 사용=> 업데이트 된 데이터 반환!

context에 이전데이터(백업용) 에 넣어줌

     => onMutate는 UI 변경부터 해주기에 error 발생시 다시 원래 UI로 돌려야함    

 

(3-3) onError & onSettled

onError: (Error, _, context) => {
  if (context?.previousPosts) {
      queryClient.setQueryData([QUERY_KEYS.POSTS], context.previousPosts);
  }
},
onSettled: () => {
  queryClient.invalidateQueries({ queryKey: [category] });
}

onMutate는 UI 변경부터 해주기에 error 발생시 다시 원래 UI로 돌려야함    

▶ onSettled 핸들러는 성공 &실패 모든 경우에 항상 호출

queryClient.invalidateQueries : [category] 쿼리를 재호출해서 데이터를 업데이트 

 = 캐시데이터에 반영하기 위한 코드 

 

 

[ (3) toggleLike : 전체 코드]

const { mutateAsync: toggleLike } = useMutation({
    mutationFn: async (params: PostCardProps) => {
      const { postId, postData } = params;
      const postRef = doc(db, 'posts', postId);

      if (postData.isLiked !== undefined && currentUserId !== undefined) {
        let newLikeCount;
        if (postData.isLiked) {
          //이미 좋아요한 경우
          newLikeCount = postData.likeCount ? postData.likeCount - 1 : 0;
        } else {
          //좋아요 안 한 경우
          newLikeCount = postData.likeCount != undefined ? postData.likeCount + 1 : 1;
        }

        await updateDoc(postRef, {
          likedUsers: postData.isLiked ? arrayRemove(currentUserId) : arrayUnion(currentUserId),
          likeCount: newLikeCount
        });
      } else {
        return;
      }
    },
  onMutate: async (params: PostCardProps) => {
      const { postId: selectedPostId } = params;
      await queryClient.cancelQueries({ queryKey: [QUERY_KEYS.POSTS, category] });

      //이전 데이터 저장
      const previousPosts = queryClient.getQueriesData<InfiniteData<PostTypeFirebase[]> | undefined>({
        queryKey: [QUERY_KEYS.POSTS, category]
      });

      queryClient.setQueryData<InfiniteData<PostTypeFirebase[]>>([QUERY_KEYS.POSTS, category], (oldData) => {
        if (!oldData) return oldData;

        const updateData = oldData?.pages.map((pagesPost) => {
          return pagesPost.map((post) => {
            const newLikeCount = post.isLiked ? post.likeCount - 1 : post.likeCount + 1;
            if (post.id === selectedPostId) {
              return {
                ...post,
                isLiked: !post.isLiked,
                likeCount: newLikeCount
              };
            } else {
              return post;
            }
          });
        });

        return {
          ...oldData,
          pages: updateData
        };
      });
      return { previousPosts };
    },
   onError: (Error, _, context) => {
      if (context?.previousPosts) {
      queryClient.setQueryData([QUERY_KEYS.POSTS], context.previousPosts);
      }
   },
  onSettled: () => {
      queryClient.invalidateQueries({ queryKey: [category] });
  }
});

 

 

끝.

반응형