학습 자료

useOptimistic — 기다리지 않는 화면


Article

메신저에서 메시지를 보내면 즉시 화면에 뜬다. 서버에 도착하기까지 0.3초가 걸리는데도 그렇다.

좋아요 버튼도 마찬가지다. 누르는 순간 하트가 채워진다. 서버 응답을 기다렸다면 버튼이 굼떠 보였을 것이다.

이걸 낙관적 업데이트라 부른다. "어차피 성공할 테니 먼저 보여주자"는 태도다.

손으로 하면 어떻게 되나

19강의 Action으로 메시지 보내기를 만들어본다.

function Thread({ messages, sendMessage }) {
  async function formAction(formData) {
    await sendMessage(formData.get("text"));
  }

  return (
    <>
      {messages.map(m => <p key={m.id}>{m.text}</p>)}
      <form action={formAction}>
        <input name="text" />
      </form>
    </>
  );
}

메시지를 보내면 0.3초 동안 아무 일도 일어나지 않는다. 서버가 답하고 messages가 갱신돼야 화면에 뜬다. 사용자는 버튼이 안 눌린 줄 안다.

직접 해결해본다.

const [pendingMessages, setPendingMessages] = useState([]);

async function formAction(formData) {
  const text = formData.get("text");
  const temp = { id: crypto.randomUUID(), text, sending: true };

  setPendingMessages(prev => [...prev, temp]);

  try {
    await sendMessage(text);
  } catch {
    // 실패했으니 도로 빼야 한다
  } finally {
    setPendingMessages(prev => prev.filter(m => m.id !== temp.id));
  }
}

const allMessages = [...messages, ...pendingMessages];

동작은 한다. 그런데 임시 목록을 따로 만들고, 성공하면 빼고, 실패해도 빼고, 진짜 목록과 합쳐야 한다. 관리할 상태가 하나 더 늘었다.

useOptimistic

React 19가 이 패턴을 훅으로 만들었다.

import { useOptimistic } from "react";

function Thread({ messages, sendMessage }) {
  const [optimisticMessages, addOptimistic] = useOptimistic(
    messages,
    (currentMessages, newText) => [
      ...currentMessages,
      { id: "temp", text: newText, sending: true },
    ]
  );

  async function formAction(formData) {
    const text = formData.get("text");
    addOptimistic(text);
    await sendMessage(text);
  }

  return (
    <>
      {optimisticMessages.map(m => (
        <p key={m.id}>
          {m.text} {m.sending && <small>보내는 중…</small>}
        </p>
      ))}
      <form action={formAction}>
        <input name="text" />
      </form>
    </>
  );
}

임시 목록도, try/catch도, 정리 코드도 없다.

이름무엇인가
인자1messages진짜 상태 (서버가 준 값)
인자2업데이트 함수(현재값, 넘긴값) → 낙관적인 값
[0]optimisticMessages화면에 그릴 값
[1]addOptimistic낙관적 갱신을 시작하는 함수

두 번째 인자는 9강의 갱신 함수와 똑같이 생겼다. 현재 값을 받아 새 값을 반환한다. 12강의 불변성 규칙도 그대로다. push가 아니라 [...current, x]다.

어떻게 되돌아오나

핵심은 여기다. useOptimistic은 Action이 끝나면 자동으로 원래 값으로 돌아간다.

메시지를 보낼 때의 시간 흐름
진짜 messages
3개4개 (서버 반영)
화면 (optimistic)
3개4개 — 보내는 중4개
서버
sendMessage 처리
제출Action 끝동기화
화면은 즉시 4개가 되고, 서버가 답하면 진짜 값으로 조용히 교체된다

성공하면 진짜 messages가 갱신되고, 낙관적 값은 사라진다. 사용자는 아무 변화도 못 느낀다.

실패하면 낙관적 값만 사라지고 원래 화면으로 돌아온다. 우리가 되돌리는 코드를 쓸 필요가 없다.

React 가 관리하는 생명주기
  1. 01

    addOptimistic 을 부른다

    업데이트 함수가 실행되어 화면에 즉시 반영된다.

    addOptimistic(text);
  2. 02

    Action 이 실행되는 동안 유지된다

    await sendMessage(text) 가 끝날 때까지 낙관적 값이 화면에 남는다.

  3. 03

    Action 이 끝나면 버린다

    성공이든 실패든 낙관적 값을 버리고 진짜 상태를 그린다.

  4. 04

    성공했다면 진짜 상태가 이미 갱신돼 있다

    그래서 화면이 그대로 보인다. 실패했다면 원래대로 돌아온다.

폼이 아니라면 19강에서 본 startTransition으로 감싼다.

function LikeButton({ liked, likeCount, onLike }) {
  const [isPending, startTransition] = useTransition();
  const [optimistic, addOptimistic] = useOptimistic(
    { liked, likeCount },
    (state, newLiked) => ({
      liked: newLiked,
      likeCount: state.likeCount + (newLiked ? 1 : -1),
    })
  );

  function handleClick() {
    startTransition(async () => {
      addOptimistic(!optimistic.liked);
      await onLike(!optimistic.liked);
    });
  }

  return (
    <button onClick={handleClick}>
      {optimistic.liked ? "♥" : "♡"} {optimistic.likeCount}
    </button>
  );
}

하트와 숫자가 즉시 바뀐다. 서버가 거절하면 조용히 원래대로 돌아온다.

언제 쓰고 언제 안 쓰나

이름 그대로 낙관적이다. 성공을 전제한다.

낙관적 업데이트가 알맞은가

알맞다

거의 항상 성공하는 일

  • 좋아요 · 북마크
  • 메시지 전송
  • 할 일 체크
  • 실패해도 손해가 작다

알맞지 않다

실패가 흔하거나 치명적인 일

  • 결제
  • 회원가입 (중복 검사)
  • 파일 업로드
  • 되돌리면 사용자가 놀란다

결제 버튼을 누르자마자 "결제 완료"라고 띄우고 3초 뒤 되돌리면, 사용자는 이미 창을 닫았을 수도 있다.

둘을 같이 쓰면 이렇게 된다.

const [error, formAction, isPending] = useActionState(async (prev, formData) => {
  const text = formData.get("text");
  addOptimistic(text);

  const result = await sendMessage(text);
  if (!result.ok) return "전송에 실패했다";
  return null;
}, null);

낙관적으로 보여주고, 실패하면 되돌아오면서 에러 메시지가 뜬다.

진짜 상태는 여전히 필요하다

useOptimistic상태를 만들지 않는다. 진짜 값은 다른 곳에 있어야 한다.

두 값의 관계
  1. 진짜 messagesprops 나 state
  2. useOptimistic잠깐 덧씌운다
  3. 화면덧씌운 값을 그린다

18강에서 Context가 "값을 전달하는 통로일 뿐 상태 관리 도구가 아니다"라고 했다. useOptimistic도 비슷하다. 진짜 값 위에 임시 값을 얹어 보여줄 뿐이다.

그래서 서버 응답이 오면 진짜 값을 갱신하는 코드가 여전히 필요하다. 그건 부모가 하거나, 프레임워크가 한다.

정리하면

  • 낙관적 업데이트는 서버 응답을 기다리지 않고 화면을 먼저 바꾼다.
  • useOptimistic(진짜값, 업데이트함수)[화면에 그릴 값, 갱신 시작 함수]를 준다.
  • 업데이트 함수는 9강의 갱신 함수처럼 생겼고, 12강의 불변성 규칙을 따른다.
  • Action이 끝나면 자동으로 되돌아간다. 성공이든 실패든 우리가 정리할 코드가 없다.
  • <form action> 안이나 startTransition 안에서만 동작한다. 그냥 부르면 즉시 되돌아간다.
  • 실패가 흔하거나 치명적인 일(결제, 가입)에는 쓰지 않는다.
  • 에러 메시지는 useActionState가 맡는다. useOptimistic은 화면만 되돌린다.

다음 강의는 마지막이다. use()가 훅의 규칙을 어떻게 비껴가는지, 그리고 React 19에서 조용히 달라진 것들을 모아 본다.

useOptimistic — 기다리지 않는 화면 — 디코드랩(DCODELAB)