React 훅: `useOptimistic`

Keonwoo Kim·2024년 1월 2일
4
post-thumbnail

React Canary 이후에서 사용할 수 있는 useOptimistic 훅에 대해 알고 계신가요? 이 훅은 비동기 작업 등이 완료되기를 기다리지 않고 클라이언트에서 예상되는 결과를 미리 띄워놓을 수 있게 하는 훅입니다.

Optimistic?

Optimistic은 "낙관적"이라는 뜻으로, 여기에서는 클라이언트가 서버에서 응답을 받지 않고도 낙관적으로 요청이 성공적으로 끝났다고 가정하는 것을 말합니다.

예를 들어 멀티플레이어 게임 서버를 생각해 봅시다. 만약 게임 클라이언트가 optimistic하게 동작하지 않는다면 다음과 같은 일이 일어날 것입니다.

  1. 오른쪽 방향키를 누를 때마다 서버에 "오른쪽 방향키 입력" 요청을 보냄
  2. 요청이 끝날 때까지 기다림
  3. 요청이 성공적으로 끝나면 위치를 오른쪽으로 1 이동

이때 클라이언트와 서버간에 요청/응답을 보내는 시간이 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 훅이 리턴하는 함수 addOptimistictransition이나 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 없이

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 Update 구현

기존 훅만을 가지고 optimistic한 상태 관리를 하는 것도 가능합니다.

먼저 필요한 state들을 생각해 봅시다. isLiked state가 있어야 하고, 클라이언트가 가지고 있을 optimistic한 값 optimisticIsLiked도 있어야 할 것입니다. 여기에 현재 실행 중인 fetch가 몇 개인지를 저장하고 있는 state가 있어야 합니다. 좋아요 버튼을 클릭하고 서버 응답을 받기 전에 다시 클릭한다면, 흔히 말하는 "랙"이 발생하여 유저 입장에서는 클릭하지도 않았는데 좋아요 상태가 바뀌는 현상이 발생하게 됩니다.

  1. 클릭 (optimistic update):
    클라이언트 false, 서버 false ——클릭——> 클라이언트 true, 서버 false
  2. 서버 응답이 오기 전에 클릭 (optimistic update):
    클라이언트 true, 서버 false ——클릭——> 클라이언트 false, 서버 false
  3. 첫 번째 요청에 대한 응답(true)이 도착, 클라이언트 상태 업데이트:
    클라이언트 true, 서버 true
  4. 두 번째 요청에 대한 응답(false)이 도착, 클라이언트 상태 업데이트:
    클라이언트 false, 서버 false

즉 마지막으로 보낸 요청에 대한 응답인지를 체크하기 위한 상태가 필요하고, 다음 runningFetchCount 상태로 추적 가능합니다.

const [isLiked, setIsLiked] = useState(initialIsLiked);
const [optimisticIsLiked, setOptimisticIsLiked] = useState(initialIsLiked);
const [runningFetchCount, setRunningFetchCount] = useState(0);

toggleLike 함수는 fetch 요청을 하기 전에 optimisticIsLikedrunningFetchCount를 업데이트 하고, fetch 요청이 끝나면 isLikedrunningFetchCount를 업데이트 해야 합니다. 또 서버와의 동기화를 위해, 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하는 함수는 다음과 같이 불리게 됩니다.

  1. 클릭 (A): currentState: false, optimisticQueue: ["A"]
    a. optimisticState = updateFn(false, "A")
    (optimisticState: true)
  2. 클릭 (B): currentState: false, optimisticQueue: ["A", "B"]
    a. optimisticState = updateFn(false, "A")
    b. optimisticState = updateFn(true, "B")
    (optimisticState: false)
  3. A (혹은 B)의 fetch가 완료되며 서버에서 true를 받아옴: setIsLiked(true)
    currentState: true, optimisticQueue: ["A", "B"]
    a. optimisticState = updateFn(true, "A")
    b. optimisticState = updateFn(false, "B")
    (optimisticState: true, false여야 함)
  4. B (혹은 A)의 fetch가 완료되며 서버에서 false를 받아옴: setIsLiked(false)
    currentState: false, optimisticQueue: ["A", "B"]
    a. optimisticState = updateFn(false, "A")
    b. optimisticState = updateFn(true, "B")
    (optimisticState: false)
  5. 모든 fetch가 완료되었으므로 optimisticQueue를 비움: 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],
  }));
}

0개의 댓글