【JavaScript】いいね・バッドボタンを実装する方法|DB集計・二重投票対策

記事や商品ページへ「いいね」「バッド」のフィードバック機能を付ける場合、ブラウザ上の変数を増やすだけでは共有カウントになりません。さらに、クライアントから現在の合計値を送ってサーバー値を上書きすると、複数ユーザーの同時操作でカウントが巻き戻り、任意の数値へ改ざんされます。

正しい設計では、クライアントは誰が・どの記事へ・どちらの投票を選んだかだけを送信します。サーバーが既存投票をDBで確認し、追加・切り替え・取り消しをトランザクション内で処理して、確定した集計結果を返します。

先に結論

  • クライアントから合計カウントを送信しません。
  • 記事IDと投票者IDの組み合わせをDBの主キーにします。
  • 同じボタンを再度押したら取り消し、反対側を押したら投票を切り替えます。
  • DB更新と集計は一つのトランザクションで処理します。
  • 画面はサーバーが返した確定値だけで更新します。
  • 通信中はボタンを無効化し、二重送信を防ぎます。
  • aria-pressedaria-liveで状態変化を伝えます。
  • 匿名IDは軽い重複抑止にすぎず、本格運用ではログインユーザーIDを使います。

Fetch APIの基本はfetch APIで非同期通信を行う方法、匿名IDの保存はlocalStorageの使い方、APIのレート制限はExpressでRate Limiterを実装する方法も参考になります。

スポンサーリンク

失敗しやすいカウント送信方式

次のように、ブラウザで増やした合計値をサーバーへ送る実装は避けてください。

bad-example.js
let likeCount = 10;

likeButton.addEventListener("click", async () => {
  likeCount += 1;

  await fetch("/api/reaction", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({
      articleId: "article-2005",
      count: likeCount
    })
  });
});

利用者AとBが同じ10を読み、それぞれ11を送ると、2票増えたのに結果は11です。また、開発者ツールからcount: 999999を送れます。クライアントは集計値ではなく、投票操作だけを送る必要があります。

今回作るAPIの仕様

メソッド URL 役割
GET /api/articles/:articleId/reaction?voterId=... 合計値と自分の投票状態を取得
POST /api/articles/:articleId/reaction like・dislikeを選択、同じ投票なら取り消し

POSTではreaction"like"または"dislike"を送ります。サーバーは現在の投票を確認し、未投票なら追加、同じ投票なら削除、異なる投票なら更新します。

HTMLでボタンと状態表示を作る

フォーム内に配置しても送信されないよう、ボタンへtype="button"を指定します。トグル状態はaria-pressed、通信結果はaria-liveで伝えます。

index.html
<section
  class="reaction-panel"
  data-reaction-panel
  data-article-id="article-2005"
  aria-label="この記事の評価"
>
  <button
    type="button"
    class="reaction-button"
    data-reaction="like"
    aria-pressed="false"
  >
    いいね
    <span data-count="like" aria-hidden="true">0</span>
  </button>

  <button
    type="button"
    class="reaction-button"
    data-reaction="dislike"
    aria-pressed="false"
  >
    バッド
    <span data-count="dislike" aria-hidden="true">0</span>
  </button>

  <p data-reaction-status aria-live="polite"></p>
</section>

<script src="/js/reactions.js" defer></script>

カウント部分をaria-hidden="true"にする場合、状態メッセージ側で「いいね12件」のように更新結果を読み上げます。件数そのものを常時読み上げたい設計なら、隠さずボタンのアクセシブル名に含めてください。

匿名投票者IDをブラウザへ保存する

ログイン機能がない簡易例では、ブラウザごとのUUIDをlocalStorageへ保存します。保存できない環境ではページ滞在中だけ同じIDを使います。

voter-id.js
const voterStorageKey = "reactionVoterId";
let fallbackVoterId = null;

function createVoterId() {
  return crypto.randomUUID();
}

function getVoterId() {
  try {
    const stored = localStorage.getItem(voterStorageKey);
    if (stored) {
      return stored;
    }

    const created = createVoterId();
    localStorage.setItem(voterStorageKey, created);
    return created;
  } catch {
    fallbackVoterId ??= createVoterId();
    return fallbackVoterId;
  }
}
匿名IDの限界:localStorageを消す、別ブラウザを使う、任意のUUIDを送ることで再投票できます。厳密な一人一票が必要なら、認証済みユーザーIDをサーバーセッションから取得し、クライアントのvoterIdを信用しない構成にします。

初期カウントと投票状態を取得する

ページ表示時に、サーバーから確定済みのカウントと自分の投票状態を取得します。HTTPエラーだけでなく、JSON解析や通信エラーも捕捉します。

load-reaction.js
async function requestJson(url, options = {}) {
  const response = await fetch(url, options);
  const data = await response.json().catch(() => null);

  if (!response.ok) {
    throw new Error(data?.message ?? `HTTP ${response.status}`);
  }

  return data;
}

async function loadReaction(articleId, voterId) {
  const params = new URLSearchParams({ voterId });

  return requestJson(
    `/api/articles/${encodeURIComponent(articleId)}/reaction?${params}`
  );
}

投票ボタンの完成版JavaScript

画面上の数値を先に増やすのではなく、サーバー応答を受け取ってから表示します。通信中は両ボタンを無効化し、連打や応答順序の逆転を防ぎます。

reactions.js
const panel = document.querySelector("[data-reaction-panel]");
const articleId = panel.dataset.articleId;
const buttons = [...panel.querySelectorAll("[data-reaction]")];
const status = panel.querySelector("[data-reaction-status]");
const voterStorageKey = "reactionVoterId";
let fallbackVoterId = null;

function createVoterId() {
  return crypto.randomUUID();
}

function getVoterId() {
  try {
    const stored = localStorage.getItem(voterStorageKey);
    if (stored) {
      return stored;
    }

    const created = createVoterId();
    localStorage.setItem(voterStorageKey, created);
    return created;
  } catch {
    fallbackVoterId ??= createVoterId();
    return fallbackVoterId;
  }
}

async function requestJson(url, options = {}) {
  const response = await fetch(url, options);
  const data = await response.json().catch(() => null);

  if (!response.ok) {
    throw new Error(data?.message ?? `HTTP ${response.status}`);
  }

  return data;
}

async function loadReaction(articleId, voterId) {
  const params = new URLSearchParams({ voterId });

  return requestJson(
    `/api/articles/${encodeURIComponent(articleId)}/reaction?${params}`
  );
}

const voterId = getVoterId();
let pending = false;

function setPending(value) {
  pending = value;
  for (const button of buttons) {
    button.disabled = value;
  }
}

function renderReaction(data) {
  panel.querySelector("[data-count='like']").textContent =
    data.counts.like;
  panel.querySelector("[data-count='dislike']").textContent =
    data.counts.dislike;

  for (const button of buttons) {
    const selected = button.dataset.reaction === data.myReaction;
    button.setAttribute("aria-pressed", String(selected));
  }

  const stateText = data.myReaction
    ? `${data.myReaction === "like" ? "いいね" : "バッド"}を選択中です。`
    : "投票していません。";

  status.textContent =
    `${stateText} いいね${data.counts.like}件、` +
    `バッド${data.counts.dislike}件。`;
}

async function submitReaction(reaction) {
  if (pending) {
    return;
  }

  setPending(true);
  status.textContent = "送信中です。";

  try {
    const data = await requestJson(
      `/api/articles/${encodeURIComponent(articleId)}/reaction`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json"
        },
        body: JSON.stringify({
          voterId,
          reaction
        })
      }
    );

    renderReaction(data);
  } catch (error) {
    status.textContent =
      `投票を保存できませんでした。${error.message}`;
  } finally {
    setPending(false);
  }
}

for (const button of buttons) {
  button.addEventListener("click", () => {
    submitReaction(button.dataset.reaction);
  });
}

setPending(true);
loadReaction(articleId, voterId)
  .then(renderReaction)
  .catch((error) => {
    status.textContent =
      `投票情報を取得できませんでした。${error.message}`;
  })
  .finally(() => {
    setPending(false);
  });

同じ投票を押した場合に取り消すか、何もしないかはサービス仕様です。この記事ではトグルボタンとして扱い、同じ投票を再度押すと未投票へ戻します。

SQLiteのテーブルを作る

記事IDと投票者IDの複合主キーにより、同じ投票者が同じ記事へ複数行を作れないようにします。投票種別もCHECK制約で限定します。

schema.sql
CREATE TABLE IF NOT EXISTS reactions (
  article_id TEXT NOT NULL,
  voter_id TEXT NOT NULL,
  reaction TEXT NOT NULL CHECK (reaction IN ('like', 'dislike')),
  created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
  PRIMARY KEY (article_id, voter_id)
);

CREATE INDEX IF NOT EXISTS idx_reactions_article_reaction
  ON reactions (article_id, reaction);

大規模サイトでは、記事テーブルへの外部キー、ユーザーテーブルへの外部キー、監査情報、論理削除などを要件に応じて追加します。集計負荷が高くなったら、キャッシュや集計テーブルも検討します。

ExpressとSQLiteを準備する

terminal
npm init -y
npm install express better-sqlite3 express-rate-limit helmet
package.json
{
  "type": "module",
  "scripts": {
    "start": "node server.js"
  }
}

ExpressにはJSONリクエストを解析するexpress.json()が組み込まれているため、この例ではbody-parserを追加しません。

DB操作をトランザクションで実装する

現在投票の取得、追加・更新・削除、再集計を一つの同期トランザクションで処理します。これにより、同じNode.jsプロセス上で操作途中の状態を返しません。

reaction-store.js
import Database from "better-sqlite3";

const db = new Database("reactions.db");
db.pragma("journal_mode = WAL");

db.exec(`
  CREATE TABLE IF NOT EXISTS reactions (
    article_id TEXT NOT NULL,
    voter_id TEXT NOT NULL,
    reaction TEXT NOT NULL
      CHECK (reaction IN ('like', 'dislike')),
    created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
    PRIMARY KEY (article_id, voter_id)
  );

  CREATE INDEX IF NOT EXISTS idx_reactions_article_reaction
    ON reactions (article_id, reaction);
`);

const findReaction = db.prepare(`
  SELECT reaction
  FROM reactions
  WHERE article_id = ? AND voter_id = ?
`);

const insertReaction = db.prepare(`
  INSERT INTO reactions (article_id, voter_id, reaction)
  VALUES (?, ?, ?)
`);

const updateReaction = db.prepare(`
  UPDATE reactions
  SET reaction = ?, updated_at = CURRENT_TIMESTAMP
  WHERE article_id = ? AND voter_id = ?
`);

const deleteReaction = db.prepare(`
  DELETE FROM reactions
  WHERE article_id = ? AND voter_id = ?
`);

const countReactions = db.prepare(`
  SELECT
    SUM(CASE WHEN reaction = 'like' THEN 1 ELSE 0 END) AS likes,
    SUM(CASE WHEN reaction = 'dislike' THEN 1 ELSE 0 END) AS dislikes
  FROM reactions
  WHERE article_id = ?
`);

function getState(articleId, voterId) {
  const current = findReaction.get(articleId, voterId);
  const counts = countReactions.get(articleId);

  return {
    myReaction: current?.reaction ?? null,
    counts: {
      like: Number(counts.likes ?? 0),
      dislike: Number(counts.dislikes ?? 0)
    }
  };
}

const toggleReactionTransaction = db.transaction(
  (articleId, voterId, requestedReaction) => {
    const current = findReaction.get(articleId, voterId);

    if (!current) {
      insertReaction.run(articleId, voterId, requestedReaction);
    } else if (current.reaction === requestedReaction) {
      deleteReaction.run(articleId, voterId);
    } else {
      updateReaction.run(
        requestedReaction,
        articleId,
        voterId
      );
    }

    return getState(articleId, voterId);
  }
);

export function readReaction(articleId, voterId) {
  return getState(articleId, voterId);
}

export function toggleReaction(articleId, voterId, reaction) {
  return toggleReactionTransaction(articleId, voterId, reaction);
}

SQLiteは一つのファイルへ保存されるため、プロセス再起動後もデータが残ります。ただし、複数台のアプリサーバーで同じローカルファイルを安全に共有する用途には向きません。その場合はPostgreSQLやMySQLなど共有DBを使い、同じ複合主キーとトランザクション設計を適用します。

Express APIを実装する

記事ID、投票者ID、投票種別を許可形式へ限定します。POSTにはレート制限を設定し、GETとPOSTの両方で確定済み状態を返します。

server.js
import express from "express";
import helmet from "helmet";
import { rateLimit } from "express-rate-limit";
import {
  readReaction,
  toggleReaction
} from "./reaction-store.js";

const app = express();
const port = 3000;

app.use(helmet());
app.use(express.json({ limit: "10kb" }));
app.use(express.static("public"));

const reactionLimiter = rateLimit({
  windowMs: 60 * 1000,
  limit: 30,
  standardHeaders: true,
  legacyHeaders: false
});

const articleIdPattern = /^[a-zA-Z0-9_-]{1,100}$/;
const voterIdPattern =
  /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
const allowedReactions = new Set(["like", "dislike"]);

function validateIds(articleId, voterId) {
  return (
    articleIdPattern.test(articleId) &&
    voterIdPattern.test(voterId)
  );
}

app.get("/api/articles/:articleId/reaction", (req, res) => {
  const { articleId } = req.params;
  const voterId =
    typeof req.query.voterId === "string" ? req.query.voterId : "";

  if (!validateIds(articleId, voterId)) {
    return res.status(400).json({
      message: "記事IDまたは投票者IDが不正です。"
    });
  }

  return res.json(readReaction(articleId, voterId));
});

app.post(
  "/api/articles/:articleId/reaction",
  reactionLimiter,
  (req, res) => {
    const { articleId } = req.params;
    const voterId =
      typeof req.body?.voterId === "string" ? req.body.voterId : "";
    const reaction =
      typeof req.body?.reaction === "string" ? req.body.reaction : "";

    if (!validateIds(articleId, voterId)) {
      return res.status(400).json({
        message: "記事IDまたは投票者IDが不正です。"
      });
    }

    if (!allowedReactions.has(reaction)) {
      return res.status(400).json({
        message: "投票種別が不正です。"
      });
    }

    const result = toggleReaction(
      articleId,
      voterId,
      reaction
    );

    return res.json(result);
  }
);

app.use((error, req, res, next) => {
  console.error(error);
  res.status(500).json({
    message: "サーバー処理に失敗しました。"
  });
});

app.listen(port, () => {
  console.log(`http://localhost:${port}`);
});

この例は同一オリジンでHTMLとAPIを配信します。Cookie認証を使う場合は、SameSite属性、CSRFトークン、Origin確認も追加してください。匿名ID方式でも、ボット対策、IP・アカウント単位の制限、監視ログが必要です。

投票操作の動きを確認する

現在状態 押したボタン DB操作 新しい状態
未投票 いいね INSERT いいね
いいね いいね DELETE 未投票
いいね バッド UPDATE バッド
バッド いいね UPDATE いいね

APIを手動テストする

curl-test.sh
VOTER_ID="550e8400-e29b-41d4-a716-446655440000"
ARTICLE_ID="article-2005"

# 初期状態を取得
curl "http://localhost:3000/api/articles/$ARTICLE_ID/reaction?voterId=$VOTER_ID"

# いいねを選択
curl -X POST \
  -H "Content-Type: application/json" \
  -d "{\"voterId\":\"$VOTER_ID\",\"reaction\":\"like\"}" \
  "http://localhost:3000/api/articles/$ARTICLE_ID/reaction"

# 同じリクエストをもう一度送ると取り消し
curl -X POST \
  -H "Content-Type: application/json" \
  -d "{\"voterId\":\"$VOTER_ID\",\"reaction\":\"like\"}" \
  "http://localhost:3000/api/articles/$ARTICLE_ID/reaction"

テストでは、未投票からlike、likeから未投票、likeからdislikeへ正しく遷移することを確認します。別のUUIDでも投票し、合計が投票行数と一致するかも確認してください。

ログインユーザー方式へ変更する

会員サイトでは、リクエスト本文のvoterIdを廃止し、認証ミドルウェアが設定したreq.user.idを使います。

authenticated-route.js
app.post(
  "/api/articles/:articleId/reaction",
  requireLogin,
  reactionLimiter,
  (req, res) => {
    const articleId = req.params.articleId;
    const voterId = String(req.user.id);
    const reaction = req.body?.reaction;

    if (!allowedReactions.has(reaction)) {
      return res.status(400).json({
        message: "投票種別が不正です。"
      });
    }

    return res.json(
      toggleReaction(articleId, voterId, reaction)
    );
  }
);

これにより、ブラウザの保存データを消しても同じアカウントでは一票だけになります。退会・アカウント統合・記事削除時のデータ処理も設計してください。

本番運用で追加したい対策

  • 記事IDが実在し、投票可能な公開記事かを確認する
  • ログインユーザーIDまたは署名済み匿名IDを利用する
  • CSRF、Origin、CORS、Cookie属性を構成に合わせて設定する
  • IP・ユーザー・記事単位でレート制限する
  • DBの複合主キーで重複行を防ぐ
  • APIエラー率や不自然な投票増加を監視する
  • 投票履歴・プライバシーポリシー・削除方針を決める
  • 大量アクセス時は集計キャッシュを検討する

よくある失敗

更新のたびにカウントが戻る

ブラウザの変数だけで管理しています。共有DBへ投票行を保存し、ページ表示時にAPIから取得します。

同時クリックで件数が合わない

クライアントが合計値を送って上書きしています。操作だけを送信し、DBの一意制約とトランザクションで更新します。

通信に失敗しても数字が増える

サーバー応答前に画面を更新しています。この記事の例のように、確定レスポンスを受け取ってから表示を更新するか、楽観的更新を行うなら失敗時に必ずロールバックします。

ボタンを連打すると投票が切り替わり続ける

リクエスト中はボタンを無効化し、サーバー側にもレート制限を設定します。フロント側だけの連打防止は改変できるため、補助策です。

よくある質問

Q. localStorageだけでいいね数を保存できますか?
A. 同じブラウザ内の簡易デモなら可能ですが、他ユーザー・別端末と共有できず、削除や改変もできます。公開サイトの集計値はサーバーDBへ保存します。
Q. 一人一票を完全に保証できますか?
A. 匿名ブラウザIDだけでは保証できません。厳密性が必要ならログイン必須にし、認証ユーザーIDと記事IDへDBの一意制約を設定します。
Q. いいねを取り消せるようにできますか?
A. できます。同じ投票を再度押した場合にDB行をDELETEし、未投票へ戻します。取り消し不可にする場合はサーバーの遷移ルールを変更します。
Q. 画面をすぐ更新する楽観的UIは使えますか?
A. 使えますが、通信失敗時のロールバック、複数リクエストの順序、サーバー確定値との再同期が必要です。最初は応答後更新の方が安全です。

まとめ

いいね・バッドボタンでは、ブラウザが計算した合計値をサーバーへ送ってはいけません。クライアントは投票操作だけを送り、サーバーが記事ID・投票者ID単位のDB行をトランザクションで追加・更新・削除し、確定カウントを返します。

画面側では初期状態をAPIから取得し、通信中の二重送信を防ぎ、サーバー応答後にaria-pressedと件数を更新します。匿名IDは軽い重複抑止に限定し、厳密な一人一票が必要なサービスでは認証ユーザーID、DB制約、レート制限、CSRF対策まで組み合わせてください。