본문 바로가기

React.js

[React 19] useOptimistic으로 낙관적 UI 구현하기 - 좋아요 버튼 UX 개선하기

들어가며

몇 달 전, React Query로 낙관적 UI를 구현하면서 꽤나 고민했던 기억이 있습니다.
그런데 React 19에서 소개된 useOptimistic을 보고 "아, 이거였구나!" 하는 깨달음을 얻었어요.
오늘은 제가 겪었던 문제를 React 19의 새로운 훅으로 어떻게 더 우아하게 해결할 수 있는지 이야기해보려 합니다! 😊

문제 상황: 느린 좋아요 버튼 🐢

인스타그램 스타일의 좋아요 기능을 구현하면서 마주쳤던 문제는 다음과 같았어요:

  • 서버 응답을 기다리느라 버튼 반응이 느림 (1-2초)
  • 연속 클릭 시 처리가 까다로움
  • 사용자 경험이 매끄럽지 않음

useOptimistic으로 해결하기 ⚡

1. 기본 구현

function LikeButton({ postId }: { postId: string }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    { count: 0, isLiked: false },
    (state, newLike: boolean) => ({
      count: state.count + (newLike ? 1 : -1),
      isLiked: newLike
    })
  );

  async function handleLike() {
    // 낙관적으로 상태 업데이트
    addOptimisticLike(!optimisticLikes.isLiked);

    try {
      await toggleLike(postId);
    } catch (error) {
      // 실패 시 자동으로 원래 상태로 복구!
      console.error('좋아요 실패:', error);
    }
  }

  return (
    <button 
      onClick={handleLike}
      className={`like-button ${optimisticLikes.isLiked ? 'active' : ''}`}
    >
      <HeartIcon />
      <span>{optimisticLikes.count}</span>
    </button>
  );
}

2. 서버 액션과 함께 사용

'use client';

function LikeButton({ postId }: { postId: string }) {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    { count: 0, isLiked: false },
    (state, newLike: boolean) => ({
      count: state.count + (newLike ? 1 : -1),
      isLiked: newLike
    })
  );

  // 서버 액션 정의
  async function toggleLikeAction(postId: string) {
    'use server';
    // 서버에서 좋아요 토글 처리
    return await db.likes.toggle(postId);
  }

  return (
    <form action={async () => {
      addOptimisticLike(!optimisticLikes.isLiked);
      await toggleLikeAction(postId);
    }}>
      <button type="submit">
        <HeartIcon filled={optimisticLikes.isLiked} />
        <span>{optimisticLikes.count}</span>
      </button>
    </form>
  );
}

React Query vs useOptimistic 🤔

이전에는 React Query로 이런 식으로 구현했었죠:

const { mutate } = useMutation({
  mutationFn: toggleLike,
  onMutate: async () => {
    await queryClient.cancelQueries(['likes', postId]);
    const previous = queryClient.getQueryData(['likes', postId]);
    queryClient.setQueryData(['likes', postId], old => ({
      ...old,
      isLiked: !old.isLiked,
      count: old.count + (old.isLiked ? -1 : 1)
    }));
    return { previous };
  },
  onError: (err, variables, context) => {
    queryClient.setQueryData(['likes', postId], context.previous);
  }
});

하지만 useOptimistic을 사용하면:

  1. 코드가 훨씬 간결해짐
  2. 롤백 처리가 자동으로 됨
  3. 서버 상태 관리와 UI 상태 관리가 깔끔하게 분리됨

고급 활용 예시 🚀

1. 애니메이션과 함께 사용

function LikeButton() {
  const [optimisticLikes, addOptimisticLike] = useOptimistic(
    { count: 0, isLiked: false },
    (state, newLike: boolean) => ({
      count: state.count + (newLike ? 1 : -1),
      isLiked: newLike
    })
  );

  return (
    <button onClick={handleLike}>
      <motion.div
        animate={{ 
          scale: optimisticLikes.isLiked ? [1, 1.2, 1] : 1 
        }}
      >
        <HeartIcon />
      </motion.div>
      <AnimatePresence>
        <motion.span
          key={optimisticLikes.count}
          initial={{ y: -10, opacity: 0 }}
          animate={{ y: 0, opacity: 1 }}
        >
          {optimisticLikes.count}
        </motion.span>
      </AnimatePresence>
    </button>
  );
}

2. 여러 상태 동시 관리

function SocialActions() {
  const [optimisticState, addOptimisticAction] = useOptimistic(
    { likes: 0, bookmarks: 0, isLiked: false, isBookmarked: false },
    (state, action: 'like' | 'bookmark') => {
      switch (action) {
        case 'like':
          return {
            ...state,
            likes: state.likes + (state.isLiked ? -1 : 1),
            isLiked: !state.isLiked
          };
        case 'bookmark':
          return {
            ...state,
            bookmarks: state.bookmarks + (state.isBookmarked ? -1 : 1),
            isBookmarked: !state.isBookmarked
          };
      }
    }
  );

  return (
    <div className="social-actions">
      <LikeButton 
        state={optimisticState} 
        onAction={() => addOptimisticAction('like')} 
      />
      <BookmarkButton 
        state={optimisticState} 
        onAction={() => addOptimisticAction('bookmark')} 
      />
    </div>
  );
}

주의사항과 팁 ⚠️

  1. 상태 설계 주의
    // 🚫 이렇게 하지 마세요
    const [optimistic, addOptimistic] = useOptimistic<number>(
    0,
    (state) => state + 1
    );
    

// ✅ 이렇게 하세요
const [optimistic, addOptimistic] = useOptimistic(
{ value: 0, lastUpdated: null },
(state) => ({
value: state.value + 1,
lastUpdated: new Date()
})
);


2. **에러 처리는 꼼꼼하게**
```tsx
try {
  addOptimisticLike(true);
  await toggleLike();
} catch (error) {
  toast.error('좋아요 처리 중 오류가 발생했습니다');
  // useOptimistic이 자동으로 상태를 복구해줍니다!
}

마치며

React 19의 useOptimistic은 정말 게임체인저라고 생각합니다.
이전에는 복잡한 코드로 구현했던 낙관적 UI를 이제는 훨씬 더 우아하게 만들 수 있게 되었네요.

특히 서버 컴포넌트와 함께 사용할 때 그 진가를 발휘하는 것 같습니다.
React가 이런 식으로 발전해나가는 걸 보면서, 프론트엔드 개발이 점점 더 재미있어지는 것 같아요!

즐거운 코딩 되세요! 🚀