이번 포스팅은 리액트19 버전에서 새롭게 등장하는 useOptimistic
훅을 사용해 간단하게 '낙관적 업데이트'를 적용하는 방법에 대해 알아보겠습니다.
useOptimistic은 추후 업데이트 예정인 리액트19 버전에서 정식으로 등장할 예정으로 현재는 React Canary
및 실험 버전에서 사용할 수 있으며, Nextjs
에서는 훅을 제공하고 있습니다.
useOptimistic 훅은 이름 그대로 optimistic ui (낙관적 업데이트)를 적용할 수 있도록 해주는 훅입니다.
기존에는 이를 구현하기 위해 tanstack-query
의 setQueryData
등을 활용 했었는데, 리액트에서 이를 기본적으로 제공하게 되었습니다!
UI를 낙관적으로 업데이트 한다는 것이 무슨 의미일까요? 개발을 하다보면 API 요청 등 비동기 작업을 처리할 일이 많습니다. 이 때 비동기 작업을 처리할 때 작업 시간이 오래걸린다면 사용자에게 결과 화면을 전달하는데에도 오랜 시간이 걸릴 것 입니다.
이럴 때 UI를 결과값을 반영해 미리 보여주는 것을 낙관적 업데이트
라고 부릅니다. 이를 통해 실제로 작업을 완료하는 데 시간이 걸려도 사용자에게 바로 결과 화면을 보여줄 수 있습니다!
(좋아요 버튼을 눌러도 즉시 반영되지 않고 딜레이가 생기는 모습)
간략한 사용 방법은 아래와 같습니다.
// example
'use client'
import { useOptimistic } from 'react';
const TodoComponent = () => {
const [optimisticTodos, addOptimisticTodo] = useOptimistic(
todos,
// 업데이트 함수
(state, newTodo) => {
return [...state, newTodo];
}
)
// ..
useOptimistic은 두 개의 매개변수를 받습니다.
state
: 초기 상태값 입니다.updateFn(currentState, optimisticValue)
: 낙관적 UI가 적용된 상태 값을 반환하는 함수로 이는 순수함수여야 합니다.다음과 같은 값을 반환합니다.
optimisticState
: 낙관적 UI가 적용된 값입니다. 비동기 작업이 진행중일 때 undateFn
에서 반환된 값이 적용됩니다.addOptimistic
: 낙관적 업데이트가 있을 때 호출하는 디스패치 함수입니다.예제를 토대로 좋아요 제출 폼에 낙관적 업데이트를 적용해 보겠습니다.
"use client";
import { useOptimistic } from "react";
// import ...
interface LikeButtonProps {
id: string;
action: (formData: FormData) => void; // eslint-disable-line
isLiked: boolean;
size?: number;
className?: string;
}
export const LikeButton = ({
id,
action,
isLiked,
size = 34,
className,
}: LikeButtonProps) => {
const [optimisticLiked, toggleOptimistic] = useOptimistic(
isLiked,
(state) => {
return !state;
},
);
const heartClass = optimisticLiked // 낙관전 UI에 따른 조건부 스타일링
? "text-red-500 fill-red-500"
: "text-red-500 hover:fill-red-500";
return (
<form
action={async (formData) => {
const id = formData.get("id") as string;
toggleOptimistic(id); // updateFn
await action(formData); // 실제 호출되는 서버액션
}}
>
<input type="hidden" name="id" value={id} />
<button type="submit">
<Heart size={size} className={cn([heartClass, className])} />
</button>
</form>
);
};
좋아요 폼은 isLiked
라는 boolean 값을 props로 받아 해당 상태에 따라 하트 이모지를 스타일링 하고 있습니다.
스타일링이 즉시 적용되는 것이 목표이기 때문에, useOptimistic의 첫 번째 인자로 isLiked 값을 넘겨주고, 두 번째 인자인 updateFn은 state 값을 변경해주는 함수를 전달합니다.
action
함수는 영화 혹은 리뷰 데이터의 id 값을 받아 좋아요를 추가하는 함수인데요, 따라서 toggleOptimistic
함수에 formData에서 추출한 id 값을 넘겨줍니다.
이제 좋아요 버튼 클릭시 UI가 즉시 반영됩니다!
만약 비동기 요청이 실패했을 경우엔 어떻게 될까요?
그럼 우리는 낙관적 업데이트를 취소하고 본래의 UI로 되돌려주어야 하겠지요. 이는 try-catch
문과 revalidate
를 통해 간단히 해결할 수 있습니다.
아래 좋아요 함수 로직을 한 번 볼까요?
"use server";
import prisma from "@/app/shared/lib/prisma";
import { revalidateTag } from "next/cache";
export const toggleLikeMovie = async (
formData: FormData,
): Promise<void | { error: string }> => {
const session = await getServerSession();
if (!session) return;
const userId = session.user?.id as string;
const movieId = formData.get("id") as string;
const existingLike = await prisma.like.findFirst({
where: {
userId,
movieId,
},
});
try {
if (existingLike) {
await prisma.like.delete({
where: {
id: existingLike.id,
},
});
} else {
await prisma.like.create({
data: {
userId,
movieId,
},
});
}
} catch (error) {
return {
error: "좋아요 요청을 보내는 동안 오류가 발생했어요 :(",
};
} finally {
revalidateTag("movieDetail");
}
};
try-catch 문을 사용해 에러가 발생했을 경우 catch 블록에서 error 객체를 반환합니다.
마지막으로 finally 블록에서 revalidateTag
를 호출하여 해당 tag로 불러왔던 데이터를 refetch 함으로써 요청이 실패하더라도 기존의 UI로 다시 되돌릴 수 있습니다.
revalidate에 대한 자세한 내용은 Nextjs 공식문서를 참고하세요!
오늘은 리액트 19버전에서 새롭게 도입되는 useOptimistic 훅을 사용하는 방법에 대해 간단히 알아보았습니다. 낙관전 UI 업데이트는 사용자에게 즉각적인 피드백을 제공함으로써 UX를 개선할 수 있는 쉽고 간단한 방법 중 하나이므로, 꼭 한 번 적용해보시길 바랍니다!