블로그 목록
프론트엔드React

React 19 useOptimistic으로 낙관적 업데이트 구현하기 — 서버 응답 전에 UI 먼저 바꾸기

폼 제출하고 서버 응답 기다리는 동안 UI가 멈춰있는 느낌. 버튼을 눌렀는데 아무 반응이 없어 보이면 다시 눌러보는 경우가 생긴다. 좋아요 버튼, 댓글 추가, 리스트 항목 삭제 같은 작업에서 특히 그렇다.

낙관적 업데이트(optimistic update)는 서버 응답을 기다리기 전에 UI를 먼저 바꿔두는 패턴이다. 실패하면 원래 상태로 돌아온다. React 19에서 useOptimistic 훅이 정식으로 포함되면서 이 패턴을 별도 라이브러리 없이 구현할 수 있게 됐다.

useOptimistic 기본 구조

const [optimisticState, addOptimistic] = useOptimistic(
  serverState,
  (currentState, optimisticValue) => {
    return [...currentState, optimisticValue];
  }
);

첫 번째 인자는 실제 서버 상태, 두 번째 인자는 낙관적 업데이트를 적용하는 리듀서다. addOptimistic를 호출하면 서버 응답 전에 UI에 즉시 반영된다. 서버 요청이 완료되면 실제 상태로 자동으로 전환된다.

댓글 추가 예시

'use client';

import { useOptimistic, useRef } from 'react';
import { addComment } from '@/actions/comment';

type Comment = {
  id: string;
  text: string;
  author: string;
  pending?: boolean;
};

export function CommentList({
  postId,
  comments,
}: {
  postId: string;
  comments: Comment[];
}) {
  const [optimisticComments, addOptimisticComment] = useOptimistic(
    comments,
    (state, newComment: Comment) => [...state, newComment]
  );

  const formRef = useRef<HTMLFormElement>(null);

  async function handleSubmit(formData: FormData) {
    const text = formData.get('comment') as string;

    addOptimisticComment({
      id: crypto.randomUUID(),
      text,
      author: '나',
      pending: true,
    });

    formRef.current?.reset();
    await addComment(postId, text);
  }

  return (
    <div>
      {optimisticComments.map((comment) => (
        <div key={comment.id} className={comment.pending ? 'opacity-60' : ''}>
          <span className="font-medium">{comment.author}</span>
          <p>{comment.text}</p>
        </div>
      ))}

      <form ref={formRef} action={handleSubmit}>
        <input name="comment" placeholder="댓글 입력..." required />
        <button type="submit">등록</button>
      </form>
    </div>
  );
}

pending: true를 붙여두면 낙관적으로 추가된 항목임을 식별할 수 있다. opacity-60 같은 스타일로 전송 중임을 시각적으로 표시하면 자연스럽다.

Server Actions와 조합

React 19의 Server Actions과 같이 쓰면 패턴이 깔끔하게 맞아떨어진다. revalidatePath로 실제 서버 데이터를 다시 불러오면 낙관적 상태가 그때 교체된다.

// actions/comment.ts
'use server';

import { prisma } from '@/lib/prisma';
import { revalidatePath } from 'next/cache';

export async function addComment(postId: string, text: string) {
  await prisma.comment.create({
    data: { postId, text, authorId: 'current-user' },
  });
  revalidatePath(`/posts/${postId}`);
}

실패 처리

서버 요청이 실패했을 때 낙관적으로 반영한 UI는 자동으로 롤백된다. useOptimistic은 실제 서버 상태를 기준으로 동작하기 때문에 별도 롤백 코드를 작성하지 않아도 된다.

에러 메시지를 사용자에게 보여줘야 한다면 try/catch로 잡아서 별도 상태로 처리하면 된다.

async function handleSubmit(formData: FormData) {
  const text = formData.get('comment') as string;

  addOptimisticComment({ id: crypto.randomUUID(), text, author: '나', pending: true });
  formRef.current?.reset();

  try {
    await addComment(postId, text);
  } catch {
    setError('댓글 등록에 실패했습니다. 다시 시도해주세요.');
  }
}

좋아요 토글 예시

단순한 토글 동작에서도 유용하다.

const [optimisticLiked, toggleOptimisticLike] = useOptimistic(
  isLiked,
  (current) => !current
);

async function handleLike() {
  toggleOptimisticLike(null);
  await toggleLike(postId);
}

버튼 클릭 직후 하트 아이콘이 즉시 채워지거나 비워지고, 서버 응답이 완료되면 실제 상태로 정리된다. 네트워크가 느린 환경에서도 인터랙션이 즉각적으로 느껴진다.


구현 복잡도가 낮고 UX 개선 효과는 즉각적이다. 서버 응답에 200ms 이상이 걸리는 작업이라면 낙관적 업데이트 적용 전후 체감 차이가 뚜렷하다. React 19에서 코어에 포함됐으니 외부 의존 없이 바로 쓸 수 있다.