낙관적 업데이트는 서버 응답 전에 결과를 먼저 보여 줘. useOptimistic가 성공과 실패 뒤에 실제 상태로 돌아오는 과정을 어떻게 관리하는지 보자.
실제 상태 위에 임시 상태를 겹쳐
useOptimistic(realState, updater)는 화면에 보여 줄 optimisticState와 임시 변경을 추가하는 함수를 돌려줘. 진행 중인 Action이 없으면 실제 상태를 그대로 보여 주고, Action이 실행되는 동안에는 updater가 만든 값을 먼저 렌더링해.
Action 안에서 먼저 화면을 바꿔
사용자가 메시지를 보내면 Action의 시작에서 임시 메시지를 추가하고 실제 요청을 기다려. 요청이 끝나 실제 상태가 갱신되면 useOptimistic는 임시 값을 버리고 서버 결과와 다시 맞춰.
실패를 숨기지 마
낙관적 업데이트는 빠르게 느끼게 하는 도구이지 성공을 보장하는 도구가 아니야. 권한 오류나 네트워크 실패가 생기면 실제 상태로 돌아가고 오류를 알려야 해. 잠깐 되돌아가는 화면은 실패한 작업을 성공한 것처럼 남기는 것보다 정직해.
useActionState와 함께 쓰기 좋아
useActionState가 서버 작업의 실제 결과와 오류를 관리하고, useOptimistic가 그 위에 즉시 반응하는 임시 화면을 만들 수 있어. 두 훅의 책임을 섞지 않으면 성공과 실패 뒤 동기화가 분명해져.
사용자에게는 즉시 반응하고 시스템에는 실제 결과를 따르게 해. 새로고침 뒤에도 남아야 하는 값은 언제나 실제 상태에서 와야 해.
Updater는 임시 값에서 새 화면 상태를 만들어
useOptimistic(messages, (current, pendingText) => [...current, temp])처럼 현재 목록과 Action이 넘긴 값을 받아 새 목록을 돌려줘. 여러 전송이 겹쳐도 각각의 pending 항목이 화면에 남도록 updater를 순수하게 작성해.
Action 안에서 호출해야 수명이 연결돼
낙관적 변경은 해당 Action이 진행되는 동안 유지되고 Action이 끝난 뒤 실제 prop 상태로 돌아와. 임시 ID와 서버 ID를 구분하고, 부모가 실제 메시지 목록을 갱신하면 같은 항목이 두 번 보이지 않게 맞춰.
좋아요 버튼처럼 되돌리기가 쉬운 작업뿐 아니라 메시지 전송에도 쓸 수 있지만, 결제나 권한 변경처럼 먼저 성공한 것처럼 보이면 위험한 작업에는 신중해야 해.
Code
useOptimistic와 useActionState로 만드는 좋아요 버튼·tsx
import { useActionState, useOptimistic } from "react";
type LikeState = { count: number };
async function toggleLike(state: LikeState, formData: FormData): Promise<LikeState> {
const delta = formData.get("delta") === "plus" ? 1 : -1;
await fetch("/api/like", {
method: "POST",
body: JSON.stringify({ delta }),
});
return { count: state.count + delta };
}
export function LikeButton({ initial }: { initial: number }) {
const [state, formAction] = useActionState(toggleLike, { count: initial });
// 낙관적 래퍼는 액션이 진행되는 동안 상태를 미리 반영해.
const [optimistic, addOptimistic] = useOptimistic(
state,
(current: LikeState, delta: number) => ({ count: current.count + delta })
);
async function handleClick(delta: 1 | -1) {
addOptimistic(delta); // UI가 즉시 count ± 1로 업데이트
const fd = new FormData();
fd.set("delta", delta === 1 ? "plus" : "minus");
await formAction(fd); // 실제 액션을 실행하고 완료되면 실제 상태로 확정해
}
return (
<div className="flex items-center gap-2">
<button onClick={() => handleClick(-1)}>-</button>
<span className="font-mono">{optimistic.count}</span>
<button onClick={() => handleClick(1)}>+</button>
</div>
);
}
낙관적으로 채팅 메시지 추가하기·tsx
import { useOptimistic } from "react";
type Message = { id: string; text: string };
export function ChatList({ messages, sendMessage }: {
messages: Message[];
sendMessage: (text: string) => Promise<void>;
}) {
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(current: Message[], pendingText: string): Message[] => [
...current,
{ id: `pending-${Date.now()}`, text: pendingText },
]
);
async function handleSend(formData: FormData) {
const text = formData.get("text") as string;
addOptimistic(text); // optimisticMessages에 즉시 나타남
await sendMessage(text);
// messages 업데이트 시 (부모가 실제 메시지로 다시 렌더링되면),
// useOptimistic가 진짜 리스트로 자동 동기화.
}
return (
<>
<ul>
{optimisticMessages.map((m) => (
<li key={m.id} className={m.id.startsWith("pending-") ? "opacity-60" : ""}>
{m.text}
</li>
))}
</ul>
<form action={handleSend}>
<input name="text" required />
<button type="submit">Send</button>
</form>
</>
);
}
실제 todo 배열은 부모 state가 소유하게 하고, 새 항목을 저장하는 가짜 endpoint는 약 30% 확률로 실패하게 만들어. useOptimistic로 제출 즉시 흐린 임시 항목을 보여 주고, 성공하면 서버 ID가 있는 실제 항목으로 교체해. 실패하면 임시 항목을 제거하고 오류와 재시도 버튼을 보여 줘. 요청 두 개를 연달아 보내도 각각의 임시 항목이 안정적으로 남는지 확인해.
Hint
Math.random() < 0.3으로 실패를 만들 수 있어. 임시 ID와 서버 ID를 구분하고, 성공·실패·동시 제출 세 경로에서 중복 항목과 사라지지 않는 임시 항목이 없는지 확인해.
Progress
Progress is local-only — sign in to sync across devices.