useOptimistic: useState 없이 구현하는 낙관적 업데이트

크리스가 채팅 앱을 개발 중이다.
"전송" 버튼을 누르면 메시지가 서버로 날아가고, DB에 저장이 완료되면 채팅창에 메시지가 뜬다.
그런데 네트워크가 조금만 느려도 사용자는 버튼을 누르고 1~2초 동안 멍하니 기다려야 한다.
"답답해! 카카오톡이나 인스타그램은 누르자마자 바로 뜨던데?"
이것을 낙관적 UI(Optimistic UI)라고 한다. 서버의 성공 여부를 확인하기 전에, "무조건 성공할 것이다"라고 낙관적으로 가정하고 UI를 먼저 업데이트해 버리는 기술이다.
크리스는 이를 구현하기 위해 기존 방식대로 코드를 짰다.
// ❌ 크리스의 수동 구현 (복잡함)
function ChatApp({ messages }) {
// 서버 데이터와 별개로 로컬 상태를 또 관리해야 한다.
const [optimisticMessages, setOptimisticMessages] = useState(messages);
const sendMessage = async (text) => {
// 1. 가짜 메시지를 먼저 리스트에 추가 (즉시 반응)
const tempMsg = { id: Date.now(), text, sending: true };
setOptimisticMessages((prev) => [...prev, tempMsg]);
try {
// 2. 서버 요청
await api.sendMessage(text);
// 3. 성공하면 서버에서 최신 목록을 다시 받아와 동기화 (리프레시)
} catch (err) {
// 4. 💥 실패하면? 아까 추가한 가짜 메시지를 찾아서 지워야 함 (Rollback)
setOptimisticMessages((prev) => prev.filter(m => m.id !== tempMsg.id));
alert('전송 실패!');
}
};
return <MessageList messages={optimisticMessages} />;
}구현은 가능하지만 고통스럽다.
React 19의 useOptimistic 훅은 이 모든 수동 관리를 자동화해 준다.
1. useOptimistic: 임시 상태의 관리자
useOptimistic은 "서버 데이터(진실)"와 "임시 데이터(거짓)" 사이를 중재한다.
비동기 작업이 진행되는 동안에는 우리가 정의한 '낙관적 상태'를 보여주다가, 작업이 끝나거나 새 데이터가 들어오면 자동으로 임시 상태를 버리고 서버 데이터로 갈아탄다.
사용법
const [optimisticState, addOptimistic] = useOptimistic(state, reducer);리팩토링: useState와 롤백 제거
크리스의 채팅 앱을 useOptimistic으로 바꿔보자.
// ChatApp.tsx
import { useOptimistic, useState } from 'react';
import { sendMessageAction } from './actions';
function ChatApp({ messages }: { messages: Message[] }) {
// ✅ 낙관적 상태 정의
// 평소에는 messages(서버 데이터)를 보여주다가,
// addOptimistic이 호출되면 배열 끝에 newMessage를 붙여서 보여준다.
const [optimisticMessages, addOptimistic] = useOptimistic(
messages,
(currentMessages, newMessage: string) => [
...currentMessages,
{ id: Date.now(), text: newMessage, sending: true }
]
);
const formAction = async (formData: FormData) => {
const text = formData.get('message') as string;
// 1. UI를 즉시 업데이트한다! (서버 응답 안 기다림)
addOptimistic(text);
// 2. 실제 서버 요청 (백그라운드)
await sendMessageAction(text);
// 3. 끝!
// 액션이 끝나면 리액트가 알아서 optimisticMessages를
// 최신 서버 데이터(messages)로 덮어쓴다. 롤백 코드가 필요 없다.
};
return (
<section>
<MessageList messages={optimisticMessages} />
<form action={formAction}>
<input name="message" />
<button type="submit">전송</button>
</form>
</section>
);
}코드를 보라. try-catch도, 롤백 로직도 없다.
리액트는 비동기 액션(formAction)이 실행되는 동안만 optimisticMessages에 가짜 데이터를 유지한다. 액션이 종료되면, 이 임시 상태를 폐기하고 부모로부터 다시 내려온 최신 props.messages로 자연스럽게 전환된다.
2. 작동 원리: 레이어(Layer) 개념
이것이 어떻게 가능한 걸까? useOptimistic을 포토샵의 레이어라고 생각하면 이해하기 쉽다.
이 방식 덕분에 개발자가 일일이 "실패했으니 배열에서 pop 해!"라고 명령할 필요가 없는 것이다.
3. 한계와 주의점
useOptimistic은 강력하지만, 만능은 아니다.
1. 반드시 비동기 액션(Action) 안에서 써야 한다
이 훅은 async/await의 생명주기와 묶여 있다. 일반적인 클릭 이벤트 핸들러보다는 <form action={...}>이나 useTransition 내부에서 사용할 때 의도대로 동작한다.
2. 복잡한 로직은 여전히 어렵다
단순히 리스트에 추가하거나 텍스트를 바꾸는 건 쉽다. 하지만 서로 다른 사용자가 동시에 같은 데이터를 수정하는 충돌 문제나, 정교한 에러 핸들링이 필요하다면 TanStack Query의 onMutate 패턴이 더 세밀한 제어를 제공할 수도 있다.
핵심 정리
React 19의 비동기 3총사(use, useActionState, useOptimistic)를 모두 익혔다. 이제 폼과 데이터 처리는 마스터했다.
그렇다면 화면 렌더링 성능은 어떨까? 무거운 계산 때문에 입력창이 버벅거릴 때, 리액트 18에서 도입되고 19에서 완성된 동시성(Concurrency) 훅들이 구세주로 등장한다.
"useTransition (Async Support) vs useDeferredValue: 동시성 렌더링의 완성" 편에서 계속된다.