記事や商品ページへ「いいね」「バッド」のフィードバック機能を付ける場合、ブラウザ上の変数を増やすだけでは共有カウントになりません。さらに、クライアントから現在の合計値を送ってサーバー値を上書きすると、複数ユーザーの同時操作でカウントが巻き戻り、任意の数値へ改ざんされます。
正しい設計では、クライアントは誰が・どの記事へ・どちらの投票を選んだかだけを送信します。サーバーが既存投票をDBで確認し、追加・切り替え・取り消しをトランザクション内で処理して、確定した集計結果を返します。
- クライアントから合計カウントを送信しません。
- 記事IDと投票者IDの組み合わせをDBの主キーにします。
- 同じボタンを再度押したら取り消し、反対側を押したら投票を切り替えます。
- DB更新と集計は一つのトランザクションで処理します。
- 画面はサーバーが返した確定値だけで更新します。
- 通信中はボタンを無効化し、二重送信を防ぎます。
aria-pressedとaria-liveで状態変化を伝えます。- 匿名IDは軽い重複抑止にすぎず、本格運用ではログインユーザーIDを使います。
Fetch APIの基本はfetch APIで非同期通信を行う方法、匿名IDの保存はlocalStorageの使い方、APIのレート制限はExpressでRate Limiterを実装する方法も参考になります。
失敗しやすいカウント送信方式
次のように、ブラウザで増やした合計値をサーバーへ送る実装は避けてください。
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で伝えます。
<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を使います。
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;
}
}
初期カウントと投票状態を取得する
ページ表示時に、サーバーから確定済みのカウントと自分の投票状態を取得します。HTTPエラーだけでなく、JSON解析や通信エラーも捕捉します。
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
画面上の数値を先に増やすのではなく、サーバー応答を受け取ってから表示します。通信中は両ボタンを無効化し、連打や応答順序の逆転を防ぎます。
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制約で限定します。
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を準備する
npm init -y npm install express better-sqlite3 express-rate-limit helmet
{
"type": "module",
"scripts": {
"start": "node server.js"
}
}
ExpressにはJSONリクエストを解析するexpress.json()が組み込まれているため、この例ではbody-parserを追加しません。
DB操作をトランザクションで実装する
現在投票の取得、追加・更新・削除、再集計を一つの同期トランザクションで処理します。これにより、同じNode.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の両方で確定済み状態を返します。
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を手動テストする
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を使います。
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の一意制約とトランザクションで更新します。
通信に失敗しても数字が増える
サーバー応答前に画面を更新しています。この記事の例のように、確定レスポンスを受け取ってから表示を更新するか、楽観的更新を行うなら失敗時に必ずロールバックします。
ボタンを連打すると投票が切り替わり続ける
リクエスト中はボタンを無効化し、サーバー側にもレート制限を設定します。フロント側だけの連打防止は改変できるため、補助策です。
よくある質問
まとめ
いいね・バッドボタンでは、ブラウザが計算した合計値をサーバーへ送ってはいけません。クライアントは投票操作だけを送り、サーバーが記事ID・投票者ID単位のDB行をトランザクションで追加・更新・削除し、確定カウントを返します。
画面側では初期状態をAPIから取得し、通信中の二重送信を防ぎ、サーバー応答後にaria-pressedと件数を更新します。匿名IDは軽い重複抑止に限定し、厳密な一人一票が必要なサービスでは認証ユーザーID、DB制約、レート制限、CSRF対策まで組み合わせてください。

