useOptimistic 이걸 활용하려고 하다가 생각보다 이해가 잘 안되서 글까지 쓰게 되었다. 이전의 쇼핑몰 프로젝트를 하면서 하트의 좋아요 버튼을 구현한 적이 있다. 그런데 바로바로 업데이트되지 않고 서버에는 데이터가 갔지만 사이트에서는 그게 보이기까지 걸리던가, 아니면 새로고침을 해서 서버에 전달됨을 알려야 했다.
그러다가 이 글을 보게 되어 유익하다고 느꼈다.
1. Optimistic이란?
: "낙관적"이라는 뜻으로, 클라이언트가 서버에서 응답을 받지 않고도 낙관적으로 요청이 성공적으로 끝났다고 가정하는 것을 말합니다. 즉, 사이트에서 서버의 요청을 받은 것처럼 행동하는 것을 의미한다.
예를 들어) 멀티플레이어 게임 서버를 생각해 봅시다. 만약 게임 클라이언트가 optimistic하게 동작하지 않는다면 다음과 같은 일이 일어날 것이다.
- 오른쪽 방향키를 누를 때마다 서버에 "오른쪽 방향키 입력" 요청을 보냄
- 요청이 끝날 때까지 기다림
- 요청이 성공적으로 끝나면 위치를 오른쪽으로 1 이동
이때 클라이언트와 서버간에 요청/응답을 보내는 시간이 100ms라고 하면, 오른쪽 키를 누를 때마다 적어도 200ms의 랙이 생기게 되고 유저는 빨라도 5fps인 뚝뚝 끊기는 게임을 경험하게 될 것이다.
게임뿐만 아니라, 일반적인 프론트엔드 앱에서도 즉각적인 UX를 위해서는 서버의 응답을 기다리지 않고도 바로 화면/UI를 업데이트하는 기능이 필요하다.
2. 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을 가지고 모두 재실행한다.
3. 하트 버튼 만들기
서버와 연동되는 좋아요 버튼을 구현해보자.
⚒ 개발 환경
- Frontend: TypeScript + React + Next.js
- Backend: Express + TypeScript + Node.js + MongoDB
하트 버튼을 만들어 좋아요 버튼으로 쓰고자 한다.
// 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;
애니메이션 기능을 쓰기 위해 tailwind.config.ts에서 위와 같이 설정해준다. 만약 tailwind.config.ts에 다른 설정이 있는 경우 위의 코드를 겹치지 않는 부분을 복사해서 붙여넣기를 해준다.
// like.tsx
import clsx from "clsx";
function like(){
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";
return (
<button
type="button"
className={clsx(
"w-32 h-32 rounded-full group hover:bg-red-500/15 transition-colors p-4",
)}
>
<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>
);
}
위와 같이 셋팅이 끝났다면 하트 버튼이 제대로 만들어진 것을 확인할 수 있을 것이다. 클릭까지 해보면 좋다.
4. 기존 훅으로 Optimistic Update 구현
위에서 만든 하트 버튼을 가지고, 기존에 있는 훅으로 Optimistic Update 구현하고자 한다.
예를 들어)
- 클릭 (optimistic update)
- 클라이언트 false, 서버 false ——클릭——> 클라이언트 true, 서버 false
- 서버 응답이 오기 전에 클릭 (optimistic update)
- 클라이언트 true, 서버 false ——클릭——> 클라이언트 false, 서버 false
- 첫 번째 요청에 대한 응답(true)이 도착, 클라이언트 상태 업데이트
- 클라이언트 true, 서버 true
- 두 번째 요청에 대한 응답(false)이 도착, 클라이언트 상태 업데이트
- 클라이언트 false, 서버 false
위의 예는 좋아요 버튼을 클릭하고 서버 응답을 받기 전에 다시 클릭한다면, 흔히 말하는 "랙"이 발생하여 유저 입장에서는 클릭하지도 않았는데 좋아요 상태가 바뀌는 현상이 발생하게 된다.
그러므로 구현하기 위해 필요한 states는 아래와 같다.
- 하트 버튼의 좋아요를 표시할 isLiked
- 클라이언트가 가지고 있을 optimistic한 값 optimisticIsLiked
- 현재 실행 중인 fetch가 몇 개인지를 저장하고 있는 runningFetchCount
- 마지막으로 보낸 요청에 대한 응답인지 체크하기 위한 상태가 필요하다. runningFetchCount 로 추적 가능하다.
const [isLiked, setIsLiked] = useState(initialIsLiked);
const [optimisticIsLiked, setOptimisticIsLiked] = useState(initialIsLiked);
const [runningFetchCount, setRunningFetchCount] = useState(0);
- fetch 요청을 하기 전에 optimisticIsLiked와 runningFetchCount를 업데이트 한다.
- fetch 요청이 끝나면 isLiked와 runningFetchCount를 업데이트 해야 한다.
- 서버와의 동기화를 위해, runningFetchCount이 0이 되면 optimisticIsLiked의 값을 isLiked로 덮어씌운다.
이해가 안 되신 분만 보세요!
위의 예시가 이해가 안 될 수 있다. 서버를 이용해 like라는 테이블을 만들어서 하트 값(boolean : true or false)을 보내 준다. 처음 하트가 보여질 때 빈하트로 보여지며, 해당 하트를 눌렀을 때 꽉찬 하트(true)값으로 바뀐다. 클릭이 안되어진다면 그대로 빈하트(false)로 보여진다. 그런데 우리는 하트의 바뀐 값을 바로 프론트에서 보여줘야 한다.
위의 사진은 이해를 돕기 위한 사진이다. 왼쪽 하트는 프론트에서 내가 꽉찬 하트(true)를 눌러서 빈 하트(false)이다. 오른쪽 하트는 서버에서 해당 하트 값이 반영이 안되서 그대로 꽉찬 하트(true)이다.
그런데 여기서 중요한 것은 서버에서 반영되는 시간이 있다. 그런데 그 시간에 사용자가 하트 여러 번 누른다면 서버에 반영되는 시간이 더 길어질 것이다. 우리는 서버에 반영이 안되도 사용자의 interactive한 환경을 보여줘야 하기에 이를 프론트에서 반영하도록 해줘야 한다.
전체코드
import React, { useEffect, useState } from "react";
import clsx from "clsx";
import axios from "axios";
require("dotenv").config();
const URL = process.env.NEXT_PUBLIC_BACKURL;
function like(props: any) {
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";
let like = props.isLiked;
const [isLiked, setIsLiked] = useState(like);
const [optimisticIsLiked, setOptimisticIsLiked] = useState(like);
const [runningFetchCount, setRunningFetchCount] = useState(0);
const toggleLike = async (id: any) => {
try {
setOptimisticIsLiked((optimisticIsLiked: any) => !optimisticIsLiked);
setRunningFetchCount((runningFetchCount) => runningFetchCount + 1);
const response = await axios.post(`${URL}/like/${id}`, {
isLiked: !isLiked,
});
if (response.data) {
console.log(response.data);
}
setIsLiked(!isLiked);
setRunningFetchCount((runningFetchCount) => runningFetchCount - 1);
} catch (error: any) {
console.error("Error creating data:", error);
}
};
useEffect(() => {
if (runningFetchCount === 0) {
setOptimisticIsLiked(isLiked);
}
}, [runningFetchCount, isLiked]);
return (
<div>
<button
type="button"
className={clsx(
"w-6 h-6 rounded-full group hover:bg-red-500/15 transition-colors pt-1"
)}
onClick={() => toggleLike(props.id)}
>
<svg className="w-6 h-6 pointer-events-none" viewBox="0 0 24 24">
<g>
<path
d={optimisticIsLiked ? FILLED_HEART : EMPTY_HEART}
className={clsx(
optimisticIsLiked
? "fill-red-500"
: "fill-slate-400 group-hover:fill-red-500/60 transition-colors",
optimisticIsLiked && "animate-appear"
)}
/>
</g>
</svg>
</button>
</div>
);
}
export default like;
하트 버튼을 만든 곳과 분리하지 않았다. 분리했는데 코드가 실행이 안되고, 오류만 뜨길래 코드를 합쳤다.
참고 사이트를 따라 기존 훅없이 따라했는데 개인적으로는 훅을 활용해서 하는 것이 좀 더 간편하게 좋은 것 같다. 훅을 많이 활용해서 그런듯하지만, 훅없는 코드들이 좀 더 길고 우선순위가 꼬여 코드가 더 길어지는 경향이 없지 않아 있다.
나중에 활용한다면 위의 코드를 좀 더 활용할 듯하다.