"use client";

import { startTransition, useOptimistic } from "react";
import { favoriteArticle, unfavoriteArticle } from "@/actions/article";
import { FaHeart } from "react-icons/fa";
import { FaRegHeart } from "react-icons/fa";
import { useUser } from "@/hooks/useUser";
import { useRouter } from "next/navigation";

interface FavoriteButtonProps {
  slug: string;
  favorited: boolean;
  favoritesCount: number;
}

export default function FavoriteButton({
  slug,
  favorited,
  favoritesCount,
}: FavoriteButtonProps) {
  // 낙관적 업데이트를 위한 상태
  const [optimisticFavorite, updateOptimisticFavorite] = useOptimistic(
    { favorited, favoritesCount },
    (state, newFavorited: boolean) => ({
      favorited: newFavorited,
      favoritesCount: newFavorited
        ? state.favoritesCount + 1
        : state.favoritesCount - 1,
    })
  );
  const { isLoggedIn } = useUser();
  const router = useRouter();
  const handleFavorite = async () => {
    if (!isLoggedIn) {
      const goToLogin = window.confirm(
        "로그인 후 이용가능합니다. 로그인 하러 가시겠습니까?"
      );
      if (goToLogin) {
        router.push("/login");
      }
      return;
    }

    // 낙관적 업데이트
    startTransition(() => {
      updateOptimisticFavorite(!optimisticFavorite.favorited);
    });

    // 서버 액션 호출
    try {
      if (optimisticFavorite.favorited) {
        await unfavoriteArticle(slug);
      } else {
        await favoriteArticle(slug);
      }
    } catch (error) {
      // 에러 발생 시 원래 상태로 되돌리기
      startTransition(() => {
        updateOptimisticFavorite(optimisticFavorite.favorited);
      });
      console.error("Failed to update favorite status", error);
    }
  };

  return (
    <button
      className={`ml-4 px-3 py-1 rounded-full text-sm flex items-center gap-1 transition-colors
        ${
          optimisticFavorite.favorited
            ? "bg-brand-primary text-white hover:bg-brand-secondary"
            : "border border-brand-primary text-brand-primary hover:bg-brand-primary hover:text-white"
        }`}
      type="button"
      onClick={handleFavorite}
    >
      {optimisticFavorite.favorited ? (
        <FaHeart className="text-red-500" />
      ) : (
        <FaRegHeart />
      )}
      <span>{optimisticFavorite.favoritesCount}</span>
    </button>
  );
}

사용자가 게시물에 heart를 누를 수 있게 하는 컴포넌트이다

상위 컴포넌트가 ssr을 할 수 있게 꼭 필요한 부분만 따로 떼어내서 클라이언트 컴포넌트로 만들었다.

 

낙관적 업데이트를 사용하기 위해

reactQuery나 swr만을 사용해서

어떻게 구현해야할까 고민했는데 

마침 딱 좋은 함수가 있어서 가져왔다.

 

 

useOptimistic hook인데 이 훅은 낙관적 업데이트를 쉽게 처리하게 해준다

import { useOptimistic } from 'react';

function AppContainer() {
  const [optimisticState, addOptimistic] = useOptimistic(
    state,
    // updateFn
    (currentState, optimisticValue) => {
      // merge and return new state
      // with optimistic value
    }
  );
}

https://ko.react.dev/reference/react/useOptimistic  

매개변수 
state: 작업이 대기 중이지 않을 때 초기에 반환될 값입니다.
updateFn(currentState, optimisticValue): 현재 상태와 addOptimistic에 전달된 낙관적인 값을 취하는 함수로, 결과적인 낙관적인 상태를 반환합니다. 순수 함수여야 합니다. updateFn은 두 개의 매개변수를 취합니다. currentState와 optimisticValue. 반환 값은 currentState와 optimisticValue의 병합된 값입니다.
반환값 
optimisticState: 결과적인 낙관적인 상태입니다. 작업이 대기 중이지 않을 때는 state와 동일하며, 그렇지 않은 경우 updateFn에서 반환된 값과 동일합니다.
addOptimistic: addOptimistic는 낙관적인 업데이트가 있을 때 호출하는 dispatch 함수입니다. 어떠한 타입의 optimisticValue라는 하나의 인자를 취하며, state와 optimisticValue로 updateFn을 호출합니다.

 

단순히 설명하면 

const [optimisticFavorite, updateOptimisticFavorite] = useOptimistic(
    { favorited, favoritesCount },
    (state, newFavorited: boolean) => ({
      favorited: newFavorited,
      favoritesCount: newFavorited
        ? state.favoritesCount + 1
        : state.favoritesCount - 1,
    })
  );
  const { isLoggedIn } = useUser();
  const router = useRouter();
  const handleFavorite = async () => {
    if (!isLoggedIn) {
      const goToLogin = window.confirm(
        "로그인 후 이용가능합니다. 로그인 하러 가시겠습니까?"
      );
      if (goToLogin) {
        router.push("/login");
      }
      return;
    }

    // 낙관적 업데이트
    startTransition(() => {
      updateOptimisticFavorite(!optimisticFavorite.favorited);
    });

    // 서버 액션 호출
    try {
      if (optimisticFavorite.favorited) {
        await unfavoriteArticle(slug);
      } else {
        await favoriteArticle(slug);
      }
    } catch (error) {
      // 에러 발생 시 원래 상태로 되돌리기
      startTransition(() => {
        updateOptimisticFavorite(optimisticFavorite.favorited);
      });
      console.error("Failed to update favorite status", error);
    }
  };

 

초기값과, 어떻게 미리 변경 할지 알려주는 코드를 넣어두면

실제로 서버에 전송하는 액션이 동작하지 않아도

updateOptimisticFavorite 이 함수를 호출하면

optimisticFavorite의 값을 바꿔준다는 것이다.

 

좀 재미있는 함수인 것 같다. 

 

주의해야할 점은

An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.

Source
components/Home/ArticleList.tsx (34:5) @ handleFavorite

  32 |   const handleFavorite = async (slug: string, favorited: boolean) => {
  33 |     // 낙관적 업데이트
> 34 |     updateOptimisticArticles({ slug, favorited: !favorited });
     |     ^
  35 |
  36 |     // 서버 액션 호출
  37 |     try {
onClick

이런 에러가 뜨는데

useOptimistic은 React의 트랜지션이나 액션 내에서만 업데이트해야 합니다.
한다는 에러인데

action은 말 그대로 server action이고

트랜지션은 
startTransition(https://ko.react.dev/reference/react/startTransition)으로 영역을 만들어 줄 수 있다.

 

startTransition을 사용하면:

React에게 이 상태 업데이트가 즉각적인 반응을 필요로 하지 않는 '전환(transition)' 작업임을 알립니다.

이 업데이트를 낮은 우선순위로 처리하여, 다른 중요한 작업(예: 사용자 입력 반응)이 방해받지 않도록 합니다.

큰 상태 변경이 있어도 UI의 반응성을 유지할 수 있습니다.

 

이런 이점을 가져다 주기 때문에 사용하라고 하는 것 같다.

 

 

 

 

 

 

 

+ Recent posts