학교 현장실습(IPP)을 시작한 지 1달이 조금 넘었다. 프로젝트를 진행하다 보니 React에 대한 이해도가 부족한 것 같다는 생각이 문득 들기 시작했고, 이에 공식 문서를 톺아보는 시리즈를 시작해보려고 한다.
그 첫 번째 훅으로 useOptimistic에 대해 알아보고자 한다.
useOptimistic을 첫 번째로 선택한 이유는, 프로젝트에서 좋아요/좋아요 취소 API를 연동할 때 요청이 성공하면 alert 창이 뜨도록 해두었는데, 팀 내에서는 알림 창이 번거롭게 느껴졌고, 즉각적인 UI 피드백이 있으면 좋겠다는 의견이 있었다. 이에 따라 useOptimistic 훅을 첫 번째 주제로 정하게 되었다.
공식 문서에서 정의한 내용을 살펴보자.
useOptimistic은 React Hook으로, 비동기 작업이 진행 중일 때 다른 상태를 보여줄 수 있게 해줍니다. 인자로 주어진 일부 상태를 받아, 네트워크 요청과 같은 비동기 작업 기간 동안 달라질 수 있는 그 상태의 복사본을 반환합니다. 현재 상태와 작업의 입력을 취하는 함수를 제공하고, 작업이 대기 중일 때 사용할 낙관적인 상태를 반환합니다.
여기서 “낙관적”이라는 말은 어떤 의미일까?
사전적 정의는 다음과 같다.
출처: 네이버 국어사전
이 뜻을 위 설명에 적용해보면, 비동기 작업이 성공할 것(잘될 것)으로 여기고 UI를 미리 업데이트시키는 것이다. 이는 사용자에게 즉각적인 피드백을 제공하여 앱이 더 빠르고 부드럽게 느껴지도록 만든다.
예를 들어, 인스타그램의 좋아요 기능을 생각해보자. 인터넷에 연결되지 않은 상태에서 좋아요 버튼을 눌러도 UI적으로는 좋아요가 반영된다. 물론 요청이 실패했다면 잠시 후에 좋아요가 취소된다.
useOptimistic은 바로 이러한 UX를 가능하게 해주는 훅이다.
import { useOptimistic } from 'react';
function AppContainer() {
const [optimisticState, addOptimistic] = useOptimistic(state, updateFn);
}
JSONPlaceholder 사이트의 /todos mock 데이터를 이용한 예시
interface TodoProps {
userId: number;
id: number;
title: string;
completed: boolean;
}
const [todos, setTodos] = useState<TodoProps[]>([]);
const [optimisticTodos, updateOptimisticTodos] = useOptimistic<
TodoProps[],
number
>(todos, (current, todoId) => {
return current.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
);
});
useEffect(() => {
const fetchTodos = async () => {
const res = await fetch("https://jsonplaceholder.typicode.com/todos");
setTodos(await res.json());
};
fetchTodos();
}, []);
const onUpdateComplete = async (todoId: number) => {
startTransition(async () => {
updateOptimisticTodos(todoId);
try {
await new Promise((resolve, reject) =>
setTimeout(() => {
const num = (Math.random() * 100).toFixed(0);
const isEven = +num % 2 === 0;
if (isEven) {
resolve(isEven);
} else {
reject(new Error("Error"));
}
}, 1000)
);
const newTodos = todos.map((todo) =>
todo.id === todoId ? { ...todo, completed: !todo.completed } : todo
);
setTodos(newTodos);
} catch (err) {
console.error(err);
}
});
};
<main>
<ul>
{optimisticTodos.map((todo) => (
<li key={todo.id} className="todo-wrapper">
<input
type="checkbox"
checked={todo.completed}
onChange={() => onUpdateComplete(todo.id)}
/>
<p>{todo.title}</p>
</li>
))}
</ul>
</main>
⚠️ 주의: formAction이 아닌 경우 반드시 startTransition 안에 작성해야 한다.
그렇지 않으면 아래와 같은 에러가 발생한다
성공 여부는 랜덤하게 설정하였다. (짝수면 resolve, 홀수면 reject)
UI가 유지되며 상태 반영이 정상적으로 완료된다.
UI는 optimistic 상태를 반영하지만, 요청 실패로 인해 원래대로 돌아간다.
데이터를 받아오거나 로그인을 할 때 로딩 UI를 통해 UX를 개선하곤 했었다.
이제는 useOptimistic 훅을 통해 좋아요, 채팅처럼 실시간성이 중요한 UI에서도 사용자 경험을 더 높일 수 있을 것 같다.