🍆

Supabaseでいいね!ボタンが連打されても余計なリクエストが飛ばないように制御する

2023/11/13に公開

ユーザーとしてTwitterを使うとき、"いいねボタン" を連打してしまうシーンはありませんか?ボタンの押下状態が瞬時に切り替わってくれるのでストレスなく使えて素晴らしいUXになっています。

このUXはぜひ個人開発でも取り入れたいですよね。
しかし、同じようなものを脳死で実装してしまうと、ボタンを押した分だけリクエストが発生してしまいます。INSERT処理やDELETE処理が連打する度に飛んでくるのは迷惑ですし、コスト面でも心配になります。

かと言って、レスポンスがあるまで待機するような仕様は、"いいね" のようなカジュアルなボタンにはUXを悪くする要因と言わざるを得ません。

そこで、極端に迷惑な連打リクエストを制御しながらもGUI上は即座に状態変化してくれる実装を実現したいと思います。

完成イメージ

▼普通な間隔で押しても違和感がない

▼エグい連打に耐える(連打の末、最終的に押下状態が一緒であれば何もしない)

0 → 1 になったときには INSERT処理。
1 → 0 になったときには DELETE処理。
0 → 0 や 1 → 1 は何もしない。(連打対策)

"何もしない" を実現したことにより、Supabaseで余計なリクエストの削減が実現できる。

こんな感じで書いてみるのはどうだろうか。

"use client";
import { useEffect, useState } from "react";
import { createBrowserClient } from "@supabase/ssr";
import type { Database } from "@/components/Types/Supabase/schema";

const LikeButton = () => {
  // 押下状態の変化の比較用としての変数として使う
  const [initialLikeState, setInitialLikeState] = useState(false);
  // 連打リクエストを制御するための変数として使う
  const [timerThrottling, setTimerThrottling] = useState<NodeJS.Timeout | null>(null);
  // コンポーネント呼び出し時、like.stateの値が変化することでuseEffectが動くことを防止するための変数として使う
  const [actionClicked, setActionClicked] = useState(false);

  // ▼DBから取得したいいね数と押下状態が入るものと思ってください。
  const [like, setLike] = useState(() => {
    return {
      count: 0,
      state: false
    }
  });
  
  useEffect(() => {
    // このコンポーネントが呼び出されたときのいいねボタンの初期状態を記憶
    setInitialLikeState(() => like.state);
  }, []);
  
  useEffect(() => {
    if (actionClicked) {
      // like.stateがクリックによって変動したときだけリクエスト処理をする
      setTimerThrottling(
        setTimeout(async() => {
          if (initialLikeState === like.state) {
            // 色々連打した結果、さっきと押下状態の変化が無ければ何もしない
	    return;
          } else {
            // 押下状態に変化があったのでSupabaseにリクエスト処理を行う
	    if (like.state) {
	      // いいね押下をした(+1カウント)
	      // SupabaseでINSERT処理を行う
	      await supabaseLikeRequest("add");
	    } else {
	      // いいね押下を解除(-1カウント)
	      // SupabaseでDELETE処理を行う
	      await supabaseLikeRequest("remove");
	    }
          }
          // 次の押下処理用に初期ステータス状態を上書きして備えておく。
	  setTimerThrottling(() => null);
	  setActionClicked(() => false);
        }, 750)
      );
    }
  }, [like.state]);
  
  const handleLike = () => {
   setActionClicked(true);
    // まだ実行が終わってない処理があれば古いイベントを破棄する
    if (timerThrottling) {
      clearTimeout(timerThrottling);
    }
    
    // ここはSupabaseリクエストを伴わないレンダリング即反映の状態を更新
    if (like.state) {
      // 押下状態でクリックしたので-1カウント
      setLike(() => {
        return {
          count: like.count - 1,
  	  state: false
	}
      });
    } else {
      // まだ押下してない状態でクリックしたので+1カウント
      setLike(() => {
        return {
          count: like.count + 1,
  	  state: true
	}
      });
    }
  }

  return (
    <button onClick={handleLike}>いいね!</button>
  );
}

/** ================================================ **/
// ここから下はSupabaseのリクエスト例
// likesのデータに変更があるとトリガーでいいね数を集計しておく処理を用意しておくといいかも。
/** ================================================ **/
const supabaseLikeRequest = (event: "add" | "remove") => {
  const supabase = createBrowserClient<Database>(process.env.NEXT_PUBLIC_SUPABASE_URL!, process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!);
  return new Promise(async(resolve) => {
    if (event === "add") {
      // いいねを行うようなDB処理
      const {data, error} = await supabase.from('likes').insert({
        "post_id": "",
        "user_id": ""
      });
      if (error) {
        resolve(error);
      }
      resolve(data);
    }
    if (event === "delete") {
      // いいねを解除するようなDB処理
      const {data, error} = await supabase.from('likes').delete().eq({
        "post_id": "",
        "user_id": ""
      })
      if (error) {
        resolve(error);
      }
      resolve(data);
    }
  });
}

これでいいねボタンが連打されてもフロントエンド上は即レンダリングでUXを悪化させることなく、わりと実用レベルで動いてくれるかと思います。

750ミリ秒はちょっと遅い感じがするので、待機時間は別途調整が必要かなと思います。ボタン押下時に最新のいいね数を取得したい場合には、SQL関数化しておき、 .rpc() でリクエスト時に最新いいね数を取得して反映してあげると更に親切になりそうですね。(リアルタイム性が必要でなければ無理に付ける必要は無いでしょう。)

Discussion