❤️

パフォーマンスを意識しながらOptimistic Updatesないいねボタンを作ろう

2024/02/13に公開

はじめに

Zenn や Twitter(X)のような、いいねを押すと即座に UI が反映されるいいねボタン ❤️ を無駄なリクエストを無くしながら実装していきたいと思います。

サーバー側の処理は、簡易的に Prisma と Next.js Route Handlers を使用しています。GitHub に完成コードを置いているので、興味があれば見てみてください。

GitHub と完成コード

https://github.com/shouta0715/zenn-playground/tree/main/src/components/like-button

完成コード(処理のみを抜粋しています)
export function useLikeButton({ initialLiked }: UseLikeButtonProps) {
  // UIに表示する状態
  const [currentLikeState, setCurrentLikeState] = useState(initialLiked);

  // レンダリングには表示しないが、実際の状態を保持する
  const realityLiked = useRef(initialLiked);

  const { isPending, mutateAsync } = useMutation({
    mutationFn: fetchLike,
    onSuccess: ({ liked }) => {
      // 実際の状態に強制的に変更する
      realityLiked.current = liked;
      setCurrentLikeState(liked);
    },
    onError: () => {
      // エラーが発生した場合は元の状態に戻す
      setCurrentLikeState(realityLiked.current);
    },
  });

  // 連打対策にdebounceを使用する
  const onDebounceLike = useDebouncedCallback(async () => {
    // 送信中であれば何もしない
    if (isPending) return;

    // 現在の状態と実際の状態が同じであれば何もしない
    if (realityLiked.current === currentLikeState) return;

    const trigger = currentLikeState ? 'like' : 'unlike';

    // 適切なpostIdを指定するように修正してください。
    await mutateAsync({ postId: 1, trigger });

    // その他の処理...
  }, 500);

  const handleLike = () => {
    // UIの状態を変更する
    setCurrentLikeState((prev) => !prev);

    // 設定したms後までに再度呼び出されなければ実行される
    onDebounceLike();
  };

  return {
    currentLikeState,
    handleLike,
  };
}

使用しているライブラリ

https://tanstack.com/query/latest
https://www.npmjs.com/package/use-debounce

Optimistic Updates とは?

通信のレスポンスを待たずに、ユーザーの操作に対して即座に UI を更新する手法です。
以下の記事に詳しく書かれていました。

https://qiita.com/devneko/items/a636b81be76b9e2137f2

考慮しなければならないこと

1. 連打をされた際に不要なリクエストを防ぐ

いいねボタンが連打されると、その回数分だけサーバーへのリクエストが送られます。これはサーバーへの不必要な負荷となるため、避けたい状況です。Optimistic Updates を用いることで、ユーザー操作による UI の即時更新は可能ですが、同時に連打による過剰なリクエストを防止する工夫が求められます。

2. サーバー側とフロント側での状態の同期

いいねの操作後、サーバーとフロントエンドの状態が不一致であると、エラーやユーザーの混乱を引き起こす可能性があります。
そのため、サーバーとフロントエンドの状態を同期させる必要があります。

3. エラーが発生した場合の処理

サーバー側でエラーが発生した際には、フロントエンドの状態を以前の状態に戻す処理が必要です。

これらのことを考慮しながら、いいねボタンを作っていきます。

実装

完成コード(処理のみを抜粋しています)
export function useLikeButton({ initialLiked }: UseLikeButtonProps) {
  // UIに表示する状態
  const [currentLikeState, setCurrentLikeState] = useState(initialLiked);

  // レンダリングには表示しないが、実際の状態を保持する
  const realityLiked = useRef(initialLiked);

  const { isPending, mutateAsync } = useMutation({
    mutationFn: fetchLike,
    onSuccess: ({ liked }) => {
      // 実際の状態に強制的に変更する
      realityLiked.current = liked;
      setCurrentLikeState(liked);
    },
    onError: () => {
      // エラーが発生した場合は元の状態に戻す
      setCurrentLikeState(realityLiked.current);
    },
  });

  // 連打対策にdebounceを使用する
  const onDebounceLike = useDebouncedCallback(async () => {
    // 送信中であれば何もしない
    if (isPending) return;

    // 現在の状態と実際の状態が同じであれば何もしない
    if (realityLiked.current === currentLikeState) return;

    const trigger = currentLikeState ? 'like' : 'unlike';

    // 適切なpostIdを指定するように修正してください。
    await mutateAsync({ postId: 1, trigger });

    // その他の処理...
  }, 500);

  const handleLike = () => {
    // UIの状態を変更する
    setCurrentLikeState((prev) => !prev);

    // 設定したms後までに再度呼び出されなければ実行される
    onDebounceLike();
  };

  return {
    currentLikeState,
    handleLike,
  };
}

1. UI に表示する状態と実際の状態を分ける

// UIに表示する状態
const [currentLikeState, setCurrentLikeState] = useState(initialLiked);

// レンダリングには表示しない、対象にいいねをしてあるかどうかの状態
const realityLiked = useRef(initialLiked);

UI に表示する状態はuseStateで管理し、実際の状態はuseRefで管理します。

useStateで管理している状態 に関しては、実際にいいねされているかどうか関係なく、見た目だけを変更すると思ってください。
2つの状態に分けることで、UI に表示する状態は即座に反映させ、データを送信するかどうかは実際の状態 useRefと比較することで判断できます。
useRefの値に関しては、いいねが押されたときの処理で使用します。

つまり、useState見た目だけの状態、useRef実際のいいねの状態を管理すると考えてください。

別々で状態を管理する理由は以下の実際の値と UI に表示する値を比較する理由で説明します。

2. useDebounce を使用して不要なリクエストを防ぐ

const onDebounceLike = useDebouncedCallback(async () => {
  // クリック後500ms 後に実行される処理
}, 500);

useDebouncedCallbackを使用して、連打された際の過剰なリクエストを防ぎます。この場合は、500ms 以内に再度呼ばれた場合は、最後に呼ばれたものだけが実行されます。

3. いいねボタンが押されたときの処理

const onDebounceLike = useDebouncedCallback(async () => {
  // 送信中であれば何もしない
  if (isPending) return;

  // 現在の状態と実際の状態が同じであれば何もしない
  if (realityLiked.current === currentLikeState) return;

  const trigger = currentLikeState ? 'like' : 'unlike';

  // 適切なpostIdを指定するように修正してください。
  await mutateAsync({ postId: 1, trigger });

  // その他の処理...
}, 500);

まず、Tanstack QueryisPendingを使用して、送信中であれば何もしないようにします。
次に、ユーザーに表示されている状態が、実際の状態と同じであれば何もしないようにします。
最後に、mutateAsyncを使用して、データを送信します。

4. データ送信後の処理

const { isPending, mutateAsync } = useMutation({
  mutationFn: fetchLike,
  onSuccess: ({ liked }) => {
    // 実際の状態に強制的に変更する
    realityLiked.current = liked;
    setCurrentLikeState(liked);
  },
  onError: () => {
    // エラーが発生した場合は元の状態に戻す
    setCurrentLikeState(realityLiked.current);
  },
});

onSuccessでは、useRefuseStateの値をサーバー側の値と同期させます。
onErrorでは、エラーが発生した場合に、useStateの値をuseRefの前の状態に戻します。

実際の値と UI に表示する値を比較する理由

値の比較を行わずuseStateのみで状態を管理した場合、以下のように動作しないパターンが生じ得ます。
初期状態として、いいねが既にされている状態 ❤️ を想定します。

  • 動作しないパターン

    1. ユーザーがいいねを解除します(ハートがグレーになります ♡)。
    2. 500ms 経過後にいいねを押す(ハートがピンクになる ❤️)
    3. さらに 500ms 以内にいいねを解除します(ハートがグレーになります ♡)。

    この場合、実行されるのは 1 回目のいいね解除と 3 回目のいいね解除の処理です。
    サーバー側から見ると、1 回目でいいねを解除したにも関わらず、次のリクエストで再びいいね解除のリクエストが送られてきます。

    いいねされていない状態で、いいね解除のリクエストが送られた場合、削除対象のデータが存在しないため、エラーが生じます。反対に、いいねの処理が 2 回実行された場合にも、同一のいいねのデータが重複してしまい、エラーになる可能性があります。

    useRefの値は、1 回目のいいね解除の後に更新されるため、3 回目の UI の状態と実際の状態が同じであるため、リクエストが送信されません。
    useRefがない場合、送信前の状態がわからないため、同じ操作のリクエストが送信されてしまいます。

    このような問題を回避するために、useRefを使用して、実際の状態を管理し、useStateを使用して、UI に表示する状態を管理します。

    なぜ useState じゃなくて useRef を使うのか?

    再レンダリングを行う必要がないためです。
    ユーザーに表示するいいねの状態は即座に反映させたいので、useStateを使用します。
    一方で、実際にいいねをされているかどうかの状態(UI ではなくデータベースなどに格納されている状態)は、再レンダリングを行う必要がないため、useRefを使用します。

まとめ

useRefを使用して、実際の状態を管理し、useStateを使用して、UI に表示する状態を管理することで、いいねボタンを作成しました。
いいねボタン以外にもフォローボタンなども同じように実装することができます。

さいごに

もし、間違いや改善点があれば、コメントで教えていただけると嬉しいです。
無限スクロール + 検索機能付きの Select Box を作成した記事も書いていますので、興味があれば見てみてください。
https://zenn.dev/shouta0715/articles/adef2340f745c3

ありがとうございました。

GitHubで編集を提案

Discussion