📌목표
1. 낙관적 UI 업데이트 알아보기
2. React19 useOptimistic 훅 작동 원리 이해하기
3. 간단한 카운터 만들고 낙관적 업데이트 적용하기
📌낙관적 UI 업데이트
- 예를 들어, 인스타그램 앱에서 게시물의 좋아요 버튼을 클릭하는 행동은 서버로 요청을 보내고, 요청이 성공했을 때 UI를 업데이트 한다. 하지만, 사용자 디바이스가 느린 네트워크 환경에 있거나, 서버의 응답이 오래 걸려서 UI 업데이트가 늦어지면 UX에 악영향을 줄 수 있다.
- 사용자가 좋아요 버튼을 클릭함과 동시에 좋아요 UI를 업데이트하여 (서버 응답을 받기 전) 사용자의 행동이 원활하게 처리되고 있음을 보여주면 UX를 개선할 수 있다. 만약 서버 응답이 실패하면, UI를 롤백하면 된다.
- 이러한 패턴을 서버 응답이 성공할 것이라 낙관적으로 예상하고, 먼저 UI를 업데이트한다고 하여, 낙관적 UI (업데이트) 라고 부른다.
📌useOptimistic Hook
- React 19 버전에서 추가된 훅으로, UI를 낙관적으로 업데이트할 수 있게 해준다. 즉 비동기 작업이 진행중일 때, 실제 상태가 아닌 다른 상태(낙관적 상태 - 실제 상태의 복사본)를 보여준다.
- 비동기 작업이 실패하면, 낙관적 상태는 원래 상태값으로 자동 롤백된다 (에러 시 상태를 직접 수정할 필요 없어서 편리)
import { useOptimistic } from 'react';
function AppContainer() {
const [optimisticState, addOptimistic] = useOptimistic(
state,
// updateFn
(currentState, optimisticValue) => {
// merge and return new state
// with optimistic value
}
);
}
매개변수
- state: 낙관적으로 만들 상태
- updateFn: 추후 addOptimistic으로 호출할 콜백 함수. currentState(매개변수 state)와 optimisticValue(addOptimistic 함수로 전달할 인수)를 조합하여 새로운 상태 반환
반환값
- optimisiticState: 낙관적으로 작동할 상태
- addOptimistic 함수: optimisticValue를 인수로 받아 호출하여 낙관적 상태 업데이트
📌간단한 카운터 만들고 낙관적 업데이트 적용하기
기능
- 화면에 실제 상태(count)와 낙관적 상태(optimisticCount)를 표시
- 버튼 클릭 시 1.5초의 의도된 지연(서버 요청과 응답을 mocking) 후 50% 확률로 성공 or 실패
- 성공 시: 실제 상태(count)를 1 증가
- 실패 시: addOptimistic 함수가 실제 상태를 변경하지 않으므로 transition 종료와 함께 낙관적 상태가 자동 롤백되어 count로 수렴
- transition 진행중일 때 버튼 비활성화
주의사항
- 낙관적 업데이트, 서버 대기, 실제 커밋(상태 업데이트) 과정을 한 흐름으로 묶어 transition 하나로 래핑해야 함
- 그렇지 않으면 낙관적 상태가 중간에 조기 리셋되어 깜빡임 현상(버튼 클릭 후 낙관적 값이 자동 롤백되어버리는 현상) 발생
"use client";
import { useOptimistic, useState, useTransition } from "react";
import { Button } from "@/components/ui/button";
export default function PlayGroundPage() {
const [count, setCount] = useState<number>(0);
const [error, setError] = useState<string | null>(null);
const [isPending, startUiTransition] = useTransition();
const [optimisticCount, addOptimisticCount] = useOptimistic<number, number>(
count,
(state, increment) => {
return state + increment;
}
);
const handleClick = () => {
startUiTransition(async () => {
setError(null);
addOptimisticCount(1); // 낙관적 업데이트
try {
await new Promise<void>(res => setTimeout(() => res(), 1500));
const isSuccess = Math.round(Math.random()) === 0;
if (!isSuccess) throw new Error("increment failed!");
setCount(prev => prev + 1);
} catch (err) {
setError((err as Error).message);
// 실패 시 optimisticCount가 count로 자동 롤백
}
});
};
return (
<div>
<h2 className="mb-4 text-2xl">Hello from Playground</h2>
<h3 className="text-xl">낙관적 카운터</h3>
<div className="flex items-center gap-4">
<span>count: {count}</span>
<span>optimisticCount: {optimisticCount}</span>
<Button onClick={handleClick} disabled={isPending}>
{isPending ? "Updating..." : "click me"}
</Button>
</div>
{error && <p className="text-sm text-red-500">{error}</p>}
</div>
);
}