React Canary 이후에서 사용할 수 있는 useOptimistic
훅에 대해 알고 계신가요? 이 훅은 비동기 작업 등이 완료되기를 기다리지 않고 클라이언트에서 예상되는 결과를 미리 띄워놓을 수 있게 하는 훅입니다.
Optimistic은 "낙관적"이라는 뜻으로, 여기에서는 클라이언트가 서버에서 응답을 받지 않고도 낙관적으로 요청이 성공적으로 끝났다고 가정하는 것을 말합니다.
예를 들어 멀티플레이어 게임 서버를 생각해 봅시다. 만약 게임 클라이언트가 optimistic하게 동작하지 않는다면 다음과 같은 일이 일어날 것입니다.
이때 클라이언트와 서버간에 요청/응답을 보내는 시간이 100ms라고 하면, 오른쪽 키를 누를 때마다 적어도 200ms의 랙이 생기게 되고 유저는 빨라도 5fps인 뚝뚝 끊기는 게임을 경험하게 될 것입니다.
게임뿐만 아니라, 일반적인 프론트엔드 앱에서도 즉각적인 UX를 위해서는 서버의 응답을 기다리지 않고도 바로 화면/UI를 업데이트하는 기능이 필요함을 알 수 있습니다.
useOptimistic
useOptimistic
훅은 바로 이런 기능을 가능케하는 훅입니다. 사용법은 다음과 같습니다.
// State는 state와 currentState의 타입입니다.
// Value는 optimisticValue의 타입입니다.
const [optimisticState, addOptimistic] = useOptimistic<State, Value>(
state,
// optimistic하게 업데이트하는 함수
(currentState: State, optimisticValue: Value) => {
return computeUpdatedState(currentState, optimisticValue);
}
);
⚠️ 주의
useOptimistic
훅이 리턴하는 함수addOptimistic
은 transition이나 form action 안에서만 사용할 수 있으며, 지켜지지 않을 시 다음과 같은 경고가 발생합니다.Warning: An optimistic state update occurred outside a transition or action. To fix, move the update to an action, or wrap with startTransition.
useOptimistic
이 optimistic value를 버리고 실제 값으로 덮어쓰기하는 시점은 실행한 transition이나 form action이 모두 종료된 시점입니다. 그 전까지는 state가 업데이트 되면 실행된 computeUpdatedState
을 새로운 state을 가지고 모두 재실행합니다.
예시를 보면서 사용법을 배워 봅시다!
간단한 예시로 서버와 연동되는 좋아요 버튼을 구현해보도록 합시다.
먼저 간단한 세팅을 위해 Next.js 프로젝트를 생성합니다. App Router와 TypeScript, Tailwind CSS를 사용하겠습니다.
서버 좋아요 상태 관리를 위한 파일 server.ts
을 만들어 주겠습니다.
import "server-only";
let isLiked = false;
export function getIsLiked() {
return isLiked;
}
export function toggleIsLiked() {
isLiked = !isLiked;
return isLiked;
}
API 역할을 할 파일 app/api/like/route.ts
를 만들어 줍니다.
import { getIsLiked, toggleIsLiked } from "@/server";
import { NextResponse } from "next/server";
export async function GET() {
const isLiked = getIsLiked();
return NextResponse.json({ isLiked });
}
export async function POST() {
// 네트워크 지연 시뮬레이션
await new Promise((resolve) =>
setTimeout(resolve, 1200 * Math.random() + 200),
);
const isLiked = toggleIsLiked();
return NextResponse.json({ isLiked });
}
그리고 하트 버튼을 만들어 주겠습니다.
// tailwind.config.ts
import type { Config } from "tailwindcss";
const config: Config = {
content: [
"./pages/**/*.{js,ts,jsx,tsx,mdx}",
"./components/**/*.{js,ts,jsx,tsx,mdx}",
"./app/**/*.{js,ts,jsx,tsx,mdx}",
],
theme: {
extend: {
keyframes: {
appear: {
"0%": { transform: "scale(0)", transformOrigin: "center center" },
"100%": { transform: "scale(1)", transformOrigin: "center center" },
},
},
animation: {
appear: "appear 0.3s cubic-bezier(.31,1.76,.72,.76) 1",
},
},
},
plugins: [],
};
export default config;
// app/Like.tsx
import clsx from "clsx";
const FILLED_HEART =
"M20.884 13.19c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z";
const EMPTY_HEART =
"M16.697 5.5c-1.222-.06-2.679.51-3.89 2.16l-.805 1.09-.806-1.09C9.984 6.01 8.526 5.44 7.304 5.5c-1.243.07-2.349.78-2.91 1.91-.552 1.12-.633 2.78.479 4.82 1.074 1.97 3.257 4.27 7.129 6.61 3.87-2.34 6.052-4.64 7.126-6.61 1.111-2.04 1.03-3.7.477-4.82-.561-1.13-1.666-1.84-2.908-1.91zm4.187 7.69c-1.351 2.48-4.001 5.12-8.379 7.67l-.503.3-.504-.3c-4.379-2.55-7.029-5.19-8.382-7.67-1.36-2.5-1.41-4.86-.514-6.67.887-1.79 2.647-2.91 4.601-3.01 1.651-.09 3.368.56 4.798 2.01 1.429-1.45 3.146-2.1 4.796-2.01 1.954.1 3.714 1.22 4.601 3.01.896 1.81.846 4.17-.514 6.67z";
export default function Like({
isLiked,
...props
}: { isLiked: boolean } & React.ComponentProps<"button">) {
return (
<button
type={props.type ?? "button"}
className={clsx(
"w-32 h-32 rounded-full group hover:bg-red-500/15 transition-colors p-4",
props.className,
)}
{...props}
>
<svg className="w-24 h-24 pointer-events-none" viewBox="0 0 24 24">
<title>{isLiked ? "Liked" : "Not liked"}</title>
<g>
<path
d={isLiked ? FILLED_HEART : EMPTY_HEART}
className={clsx(
isLiked
? "fill-red-500"
: "fill-slate-400 group-hover:fill-red-500/60 transition-colors",
isLiked && "animate-appear",
)}
/>
</g>
</svg>
</button>
);
}
Optimistic update 없이 클릭 시에 요청을 보내고 응답이 왔을 때 하트를 업데이트하도록 구현해 봅시다.
// app/NaivelySyncedLike.tsx
"use client";
import { useState } from "react";
import Like from "./Like";
export default function NaivelySyncedLike({
initialIsLiked,
}: { initialIsLiked: boolean }) {
const [isLiked, setIsLiked] = useState(initialIsLiked);
async function toggleLike() {
const { isLiked } = await fetch("/api/like", { method: "POST" }).then(
(response) => response.json() as Promise<{ isLiked: boolean }>,
);
setIsLiked(isLiked);
}
return <Like isLiked={isLiked} onClick={toggleLike} />;
}
// app/page.tsx
import { getIsLiked } from "@/server";
import NaivelySyncedLike from "./NaivelySyncedLike";
export default function Home() {
const isLiked = getIsLiked();
return (
<main className="flex h-dvh justify-center items-center gap-8">
<section className="flex flex-col justify-center items-center">
<NaivelySyncedLike initialIsLiked={isLiked} />
<span>NaivelySyncedLike</span>
</section>
</main>
);
}
위와 같이 클릭 후에 딜레이가 생기게 됩니다.
기존 훅만을 가지고 optimistic한 상태 관리를 하는 것도 가능합니다.
먼저 필요한 state들을 생각해 봅시다. isLiked
state가 있어야 하고, 클라이언트가 가지고 있을 optimistic한 값 optimisticIsLiked
도 있어야 할 것입니다. 여기에 현재 실행 중인 fetch가 몇 개인지를 저장하고 있는 state가 있어야 합니다. 좋아요 버튼을 클릭하고 서버 응답을 받기 전에 다시 클릭한다면, 흔히 말하는 "랙"이 발생하여 유저 입장에서는 클릭하지도 않았는데 좋아요 상태가 바뀌는 현상이 발생하게 됩니다.
false
, 서버 false
——클릭——> 클라이언트 true
, 서버 false
true
, 서버 false
——클릭——> 클라이언트 false
, 서버 false
true
)이 도착, 클라이언트 상태 업데이트:true
, 서버 true
false
)이 도착, 클라이언트 상태 업데이트:false
, 서버 false
즉 마지막으로 보낸 요청에 대한 응답인지를 체크하기 위한 상태가 필요하고, 다음 runningFetchCount
상태로 추적 가능합니다.
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [optimisticIsLiked, setOptimisticIsLiked] = useState(initialIsLiked);
const [runningFetchCount, setRunningFetchCount] = useState(0);
toggleLike
함수는 fetch
요청을 하기 전에 optimisticIsLiked
와 runningFetchCount
를 업데이트 하고, fetch
요청이 끝나면 isLiked
와 runningFetchCount
를 업데이트 해야 합니다. 또 서버와의 동기화를 위해, runningFetchCount
이 0이 되면 optimisticIsLiked
의 값을 isLiked
로 덮어씌웁니다.
// app/SyncedLikeWithoutOptimistic.tsx
"use client";
import { useEffect, useState } from "react";
import Like from "./Like";
export default function SyncedLikeWithoutOptimistic({
initialIsLiked,
}: { initialIsLiked: boolean }) {
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [optimisticIsLiked, setOptimisticIsLiked] = useState(initialIsLiked);
const [runningFetchCount, setRunningFetchCount] = useState(0);
async function toggleLike() {
// Optimistic update
setOptimisticIsLiked((optimisticIsLiked) => !optimisticIsLiked);
// fetch 카운트 증가
setRunningFetchCount((runningFetchCount) => runningFetchCount + 1);
// Fetch 호출
const { isLiked } = await fetch("/api/like", { method: "POST" }).then(
(response) => response.json() as Promise<{ isLiked: boolean }>,
);
// 상태 업데이트
setIsLiked(isLiked);
// fetch 카운트 감소
setRunningFetchCount((runningFetchCount) => runningFetchCount - 1);
}
useEffect(() => {
if (runningFetchCount === 0) {
setOptimisticIsLiked(isLiked);
}
}, [runningFetchCount, isLiked]);
return <Like isLiked={optimisticIsLiked} onClick={toggleLike} />;
}
useOptimistic
을 사용하여 구현위와 똑같은 좋아요 버튼을 useOptimistic
을 이용하여 구현해 보겠습니다. useOptimistic
은 transition이나 form action 안에서만 작동하므로, 여기서는 form action을 사용해서 구현해 보도록 하겠습니다.
먼저 좋아요 버튼을 form으로 감싸야 합니다.
// 리턴하는 JSX
<form action={toggleAction}>
<Like type="submit" isLiked={optimisticState.isLiked} />
</form>
State 타입이 어떻게 되어야 할지 생각해 봅시다. 대충 생각해 보면 type State = boolean
으로 충분할 것 같습니다. 이 경우에 useOptimistic
호출은 다음과 같이 되겠습니다.
const [isLiked, setIsLiked] = useState(initialIsLiked);
const state = isLiked;
const updateFn = (currentState, requestId) => {
return !currentState;
};
const [optimisticState, addOptimisticValue] =
useOptimistic<boolean, string>(state, updateFn);
하지만 useOptimistic
이 어떻게 작동하는지를 생각하면 boolean
만으로는 부족합니다. useOptimistic
가 리턴하는 addOptimisticValue
는 들어온 값을 큐에 넣어두고, state가 바뀔 때마다 저장된 큐에서 하나씩 뽑아 updateFn
를 실행하는 식으로 동작합니다. (Array.reduce를 생각하시면 되겠습니다.)
처음 state가 false
인 상태에서 연속으로 두 번 클릭한 상황을 가정해 봅시다. 이때 useOptimistic
의 state를 update하는 함수는 다음과 같이 불리게 됩니다.
currentState: false, optimisticQueue: ["A"]
optimisticState = updateFn(false, "A")
optimisticState: true
)currentState: false, optimisticQueue: ["A", "B"]
optimisticState = updateFn(false, "A")
optimisticState = updateFn(true, "B")
optimisticState: false
)setIsLiked(true)
currentState: true, optimisticQueue: ["A", "B"]
optimisticState = updateFn(true, "A")
optimisticState = updateFn(false, "B")
optimisticState: true
, false
여야 함)setIsLiked(false)
currentState: false, optimisticQueue: ["A", "B"]
optimisticState = updateFn(false, "A")
optimisticState = updateFn(true, "B")
optimisticState: false
)currentState: false
optimisticQueue
에 있는 값이 state에서 이미 처리된 값인지 확인할 수 없기 때문에 생기는 문제입니다. 이를 해결하기 위해서는 State
에 이미 처리한 request의 ID들을 담아둘 필요가 있습니다.
type State = { isLiked: boolean; requestIds: string[] };
type Value = { requestId: string };
그리고 이 requestIds
를 이용해서 이미 처리된 request는 무시합니다.
const [optimisticState, addOptimisticValue] = useOptimistic<State, Value>(
state,
(currentState, { requestId }) => {
// 이미 처리된 request라면 무시
if (currentState.requestIds.includes(requestId)) {
return currentState;
}
// 아직 처리되지 않은 request이면 isLiked를 토글 후 requestIds에 추가
return {
...currentState,
isLiked: !currentState.isLiked,
requestIds: [...currentState.requestIds, requestId],
};
},
);
마지막으로 toggleAction
을 구현해 주면 됩니다.
async function toggleAction() {
const requestId = nanoid();
// Optimistic update
addOptimisticValue({ requestId });
// Fetch 호출
const response = await fetch("/api/like", { method: "POST" });
const { isLiked } = (await response.json()) as { isLiked: boolean };
// 상태 업데이트
setState((state) => ({
isLiked,
requestIds: [...state.requestIds, requestId],
}));
}
예제 코드도 너무 잘 나와있어서 이해하는 데 도움을 많이 받았습니다. 감사합니다..
혹시 괜찮으시다면 예제 코드 수정해서 제 포스트에서 사용해도 괜찮을까요?
출처 꼭 남기겠습니다!