はじめに
近況共有アプリに、投稿へ「いいね」できる機能を追加しました。
この記事では、React + Hono + Cloudflare D1 + Drizzle ORM で、投稿いいねをどう設計して実装したかをまとめます。
作るもの
今回作るのは、投稿一覧のカードに付くいいねボタンです。
要件は次のようにしました。
- 投稿ごとにいいね数を表示する
- ログイン中のユーザーは他人の投稿にいいねできる
- いいね済みの投稿はハートを塗る
- もう一度押すと取り消せる
- 1ユーザーは1投稿に1回だけいいねできる
- 未ログインで押した場合はログイン画面へ送る
- 自分の投稿にはいいねボタンを出さない
- リアルタイム反映はしない
リアルタイム反映は対象外にしました。
押した本人の画面はAPIレスポンスで更新しますが、他の人の画面は再読み込みや再取得のタイミングで反映されれば十分だと考えました。
まずは、いいねを「押せる」「取り消せる」「重複しない」状態にすることを優先しています。
設計
採用した設計は、いいね専用テーブルを作る形です。
投稿いいねは、user_id と classmate_id の関係として保存します。
users
-> classmate_likes
-> classmates
classmate_likes は中間テーブルで、2つのテーブルの関係を表すためのテーブルです。
今回なら、users と classmates を直接つなぐのではなく、間に classmate_likes を置きます。
たとえば、次のような状態です。
users
id = 1
classmates
id = 10
classmate_likes
user_id = 1
classmate_id = 10
これは、id = 1 のユーザーが、id = 10 の投稿にいいねした、という意味になります。
classmates に like_count = 1 だけを持たせると、「1件いいねされた」ことは分かりますが、誰が押したかは分かりません。
classmate_likes に1いいね1行で保存しておくと、「このユーザーはこの投稿にいいね済みか」「このユーザーのいいねだけ取り消すにはどの行を消せばよいか」を判断できます。
この設計にすると、次の判断がしやすくなります。
-
classmate_idごとに数えればlikeCountを出せる -
user_idとclassmate_idの組み合わせを見ればlikedByMeを出せる - 同じ組み合わせに unique index を張れば重複いいねを防げる
- 削除時は自分の
user_idに紐づく行だけ消せばよい
APIは、追加と取り消しを分けました。
POST /api/groups/:slug/classmates/:id/like
DELETE /api/groups/:slug/classmates/:id/like
レスポンスでは、毎回現在のいいね状態を返します。
{
"like": {
"classmateId": 123,
"likeCount": 5,
"likedByMe": true
}
}
フロントエンドは、このレスポンスを使って該当投稿だけ更新します。
一覧全体を再取得する方法もありますが、いいねを1つ押すたびにフィード全体を取り直す必要はないと判断しました。
別案
一番単純な案は、classmates に like_count を持たせることです。
classmates
id
name
comment
like_count ← 追加
表示だけならこの方法でも動きますが、取り消しと重複防止が弱くなります。
たとえば、同じユーザーが2回押したときにどう扱うか、自分が押したいいねだけをどう減らすか、という問題が残ります。
もう1つの案は、フロントエンドだけで likedByMe を持つことです。
これはさらに簡単ですが、リロードすると状態が消えます。別端末や別ブラウザとの整合性も取れません。
いいねは複数ユーザーが触るデータなので、最終的な事実はDBに置くべきだと考えました。
そのため、今回は classmate_likes テーブルを採用しています。
DB設計
Drizzle の schema には、classmate_likes を追加しました。
export const classmateLikes = sqliteTable(
"classmate_likes",
{
id: integer("id").primaryKey({ autoIncrement: true }),
classmateId: integer("classmate_id")
.notNull()
.references(() => classmates.id, { onDelete: "cascade" }),
userId: integer("user_id")
.notNull()
.references(() => users.id, { onDelete: "cascade" }),
createdAt: integer("created_at", { mode: "timestamp" }).notNull(),
},
(table) => [
uniqueIndex("classmate_likes_classmate_user_unique").on(
table.classmateId,
table.userId,
),
index("classmate_likes_classmate_idx").on(table.classmateId),
index("classmate_likes_user_idx").on(table.userId),
],
);
一番大事なのは、classmateId と userId の unique index です。
uniqueIndex("classmate_likes_classmate_user_unique").on(
table.classmateId,
table.userId,
);
UIでボタンを連打できないようにしても、それだけでは不十分です。
通信のリトライ、二重送信、直接APIアクセスがあっても、最後はDB制約で守る必要があります。
いいねのような機能は「一見UIの状態」に見えますが、実際にはDB制約まで含めて設計した方が安定します。
API設計
投稿一覧APIでは、各投稿に likeCount と likedByMe を追加しました。
GET /api/groups/:slug/classmates
レスポンスは次のような形です。
{
"classmates": [
{
"id": 123,
"name": "山田 太一",
"currentLocation": "福岡市",
"job": "小学校の先生",
"comment": "最近の近況",
"snsUrl": "",
"createdAt": "2026-06-08T09:30:00.000Z",
"avatarUrl": null,
"likeCount": 3,
"likedByMe": false,
"commentCount": 0
}
]
}
likeCount は全員に見せる値です。
likedByMe は、ログイン中のユーザーがその投稿にいいね済みかを表します。未ログインの場合は false にしています。
一覧取得では、投稿を取得したあとに投稿ID一覧を使っていいね情報をまとめて集計しました。
const classmateIds = rows.map((classmate) => classmate.id);
const likeSummaries = await getClassmateLikeSummaries(
c,
classmateIds,
session?.user.id ?? null,
);
レスポンスを作るときに、投稿ごとの状態を足します。
return c.json({
classmates: rows.map(({ avatarUpdatedAt, ...classmate }) => ({
...classmate,
avatarUrl: avatarUpdatedAt
? getPublicClassmateAvatarUrl(classmate.id, avatarUpdatedAt)
: null,
likeCount: likeSummaries.likeCountByClassmateId.get(classmate.id) ?? 0,
likedByMe: likeSummaries.likedClassmateIds.has(classmate.id),
commentCount: commentCountByClassmateId.get(classmate.id) ?? 0,
})),
});
いいね数は count() と groupBy() で集計します。
const likeCountRows = await db
.select({
classmateId: classmateLikes.classmateId,
likeCount: count(classmateLikes.id),
})
.from(classmateLikes)
.where(inArray(classmateLikes.classmateId, classmateIds))
.groupBy(classmateLikes.classmateId);
ログイン中の場合だけ、自分がいいねした投稿IDも取得しました。
const likedRows = await db
.select({
classmateId: classmateLikes.classmateId,
})
.from(classmateLikes)
.where(
and(
inArray(classmateLikes.classmateId, classmateIds),
eq(classmateLikes.userId, userId),
),
);
結果は Map と Set にしておくと、レスポンス作成時に投稿IDから直接引けます。
const likeCountByClassmateId = new Map<number, number>();
const likedClassmateIds = new Set<number>();
たとえば likeCountRows が配列のままだと、投稿ごとに該当行を探す必要があります。
const likeCount =
likeCountRows.find((row) => row.classmateId === classmate.id)?.likeCount ?? 0;
Map にしておけば、投稿IDをキーにしてそのまま取得できます。
const likeCount = likeCountByClassmateId.get(classmate.id) ?? 0;
Set は、自分がいいねした投稿IDの一覧として使います。
const likedByMe = likedClassmateIds.has(classmate.id);
つまり、Map は「投稿IDからいいね数を引くため」、Set は「投稿IDが自分のいいね済み一覧に含まれるかを見るため」に使っています。
いいね追加API
追加は POST にしました。
POST /api/groups/:slug/classmates/:id/like
ログイン必須で、CSRFチェックも通します。
app.post("/api/groups/:slug/classmates/:id/like", async (c) => {
const session = await requireSession(c);
await verifyCsrf(c, session);
const db = drizzle(c.env.DB);
const { classmate } = await getPublicClassmateInGroup(c);
if (classmate.authorUserId === session.user.id) {
throw new HTTPException(400, { message: "自分の投稿にはいいねできません" });
}
await db
.insert(classmateLikes)
.values({
classmateId: classmate.id,
userId: session.user.id,
createdAt: new Date(),
})
.onConflictDoNothing({
target: [classmateLikes.classmateId, classmateLikes.userId],
});
const like = await getClassmateLikeState(c, classmate.id, session.user.id);
c.header("Cache-Control", "no-store");
return c.json({ like });
});
対象投稿の取得には、共通関数を使っています。
async function getPublicClassmateInGroup(c: Parameters<typeof getCookie>[0]) {
const { group, classmateId } = await getGroupAndClassmateId(c);
const db = drizzle(c.env.DB);
const classmate = await db
.select({
id: classmates.id,
authorUserId: classmates.authorUserId,
})
.from(classmates)
.where(
and(
eq(classmates.id, classmateId),
eq(classmates.groupId, group.id),
eq(classmates.status, "published"),
eq(classmates.visibility, "public"),
),
)
.get();
if (!classmate) {
throw new HTTPException(404, { message: "投稿が見つかりません" });
}
return { group, classmate };
}
削除済み、非公開、別グループの投稿は、いいね対象として見つからない扱いにしています。
ここを緩くすると、URLの id を差し替えて本来触れない投稿に操作できる余地が出るからです。
重複いいねの扱い
いいね追加では onConflictDoNothing() を使いました。
.onConflictDoNothing({
target: [classmateLikes.classmateId, classmateLikes.userId],
})
すでにいいね済みの場合でも、エラーにはせずに現在の状態を返します。
{
"like": {
"classmateId": 123,
"likeCount": 5,
"likedByMe": true
}
}
この形にした理由は、クライアント側を扱いやすくするためです。
通信が二重に走っても、最終的に「いいね済み」という状態が返れば、画面は正しい状態へ寄せられます。
いいね取り消しAPI
取り消しは DELETE にしました。
DELETE /api/groups/:slug/classmates/:id/like
削除対象は、ログイン中ユーザー自身のいいねだけです。
app.delete("/api/groups/:slug/classmates/:id/like", async (c) => {
const session = await requireSession(c);
await verifyCsrf(c, session);
const db = drizzle(c.env.DB);
const { classmate } = await getPublicClassmateInGroup(c);
await db
.delete(classmateLikes)
.where(
and(
eq(classmateLikes.classmateId, classmate.id),
eq(classmateLikes.userId, session.user.id),
),
);
const like = await getClassmateLikeState(c, classmate.id, session.user.id);
c.header("Cache-Control", "no-store");
return c.json({ like });
});
未いいね状態で DELETE しても、エラーにはしていません。
追加と同じく、最後に現在状態を返します。
{
"like": {
"classmateId": 123,
"likeCount": 4,
"likedByMe": false
}
}
追加も削除も、操作結果ではなく現在状態を返すようにしました。
フロントエンド実装
フロントエンドの型では、投稿一覧にいいね情報を追加しました。
export type PublicClassmate = Pick<
Classmate,
"id" | "name" | "currentLocation" | "job" | "comment" | "snsUrl" | "createdAt"
> & {
avatarUrl: string | null;
likeCount: number;
likedByMe: boolean;
commentCount: number;
};
いいね操作の結果も型にしています。
export type ClassmateLikeState = {
classmateId: number;
likeCount: number;
likedByMe: boolean;
};
APIクライアントには、追加と取り消しの関数を用意しました。
export async function likeClassmate(slug: string, classmateId: number) {
return request<{ like: ClassmateLikeState }>(
`/api/groups/${slug}/classmates/${classmateId}/like`,
{
method: "POST",
},
);
}
export async function unlikeClassmate(slug: string, classmateId: number) {
return request<{ like: ClassmateLikeState }>(
`/api/groups/${slug}/classmates/${classmateId}/like`,
{
method: "DELETE",
},
);
}
このアプリでは、共通の request() がGET以外のリクエストにCSRFトークンを付けるので、いいねAPIもその仕組みに乗せました。
UIコンポーネントで POST と DELETE を直接分岐したくなかったので、usecase を作っています。
export async function toggleClassmateLike({
classmateId,
likedByMe,
slug,
}: {
classmateId: number;
likedByMe: boolean;
slug: string;
}): Promise<ToggleClassmateLikeResult> {
try {
const response = likedByMe
? await unlikeClassmate(slug, classmateId)
: await likeClassmate(slug, classmateId);
return { like: response.like, type: "updated" };
} catch (error) {
if (error instanceof ApiError && error.status === 401) {
return { type: "needsLogin" };
}
throw error;
}
}
未ログインの場合は、例外をそのまま画面へ投げず、needsLogin として返します。
ページ側は、この結果を見てログイン画面へ遷移させます。
一覧状態の更新
いいね後は、一覧全体を再取得せず、該当投稿だけ更新しました。
useClassmates では、APIから返った ClassmateLikeState を使って classmates を差し替えています。
const applyLikeState = useCallback((like: ClassmateLikeState) => {
setState((currentState) => ({
...currentState,
classmates: currentState.classmates.map((classmate) =>
classmate.id === like.classmateId
? {
...classmate,
likeCount: like.likeCount,
likedByMe: like.likedByMe,
}
: classmate,
),
}));
}, []);
通信中の投稿IDは Set で管理しました。
const [likingClassmateIds, setLikingClassmateIds] = useState<Set<number>>(
() => new Set(),
);
これにより、いいね処理中は対象投稿のボタンだけを無効化できます。
const toggleLike = useCallback(
async (classmate: PublicClassmate) => {
setLikingClassmateIds((currentIds) => {
const nextIds = new Set(currentIds);
nextIds.add(classmate.id);
return nextIds;
});
try {
const like = await toggleClassmateLike({
classmateId: classmate.id,
likedByMe: classmate.likedByMe,
slug,
});
if (like.type === "updated") {
applyLikeState(like.like);
}
return like;
} finally {
setLikingClassmateIds((currentIds) => {
const nextIds = new Set(currentIds);
nextIds.delete(classmate.id);
return nextIds;
});
}
},
[applyLikeState, slug],
);
フィード全体を取り直すより、操作した投稿だけ更新する方が、画面の動きは軽くなります。
ただし、他ユーザーが同時に押した分まですぐ同期したいなら、再取得やリアルタイム配信が必要です。
今回はMVPなので、そこまでは入れていません。
カードのボタン
投稿カードでは、likedByMe によって見た目とアクセシビリティ属性を変えています。
const likeLabel = classmate.likedByMe ? "いいねを取り消す" : "いいねする";
ボタンには aria-pressed を付けました。
<button
aria-label={likeLabel}
aria-pressed={classmate.likedByMe}
className={[
"inline-flex h-8 min-w-24 items-center gap-1 rounded-full px-2 font-bold transition disabled:opacity-60",
classmate.likedByMe
? "text-rose-600 hover:bg-rose-50"
: "text-stone-600 hover:bg-stone-100",
].join(" ")}
disabled={isLikePending}
onClick={onLikeClick}
type="button"
>
<Heart className={classmate.likedByMe ? "fill-rose-500" : ""} size={18} />
いいね {classmate.likeCount}
</button>
いいね済みの状態はユーザーが直感的にわかるように赤系にしました。
未いいね状態はグレーのままにして、押した後だけ強く見えるようにしています。
自分の投稿では、ボタンではなく読み取り表示にしました。
{
canEdit ? (
<span className="inline-flex h-8 min-w-24 items-center gap-1 rounded-full px-2">
<Heart size={18} />
いいね {classmate.likeCount}
</span>
) : (
<button>いいね {classmate.likeCount}</button>
);
}
一応、API側でも自分の投稿へのいいねを拒否しています。
if (classmate.authorUserId === session.user.id) {
throw new HTTPException(400, { message: "自分の投稿にはいいねできません" });
}
画面の制御は使いやすさのため、APIの制御はデータを守るために置いています。
エッジケース
いいね機能では、次のケースを意識しました。
- 未ログインで押した場合
- 同じ投稿に連続で押した場合
- すでにいいね済みなのに
POSTされた場合 - 未いいねなのに
DELETEされた場合 - 自分の投稿にいいねしようとした場合
- 削除済み、非公開、別グループの投稿を指定された場合
未ログインの場合は、usecase が needsLogin を返します。
ページ側ではログイン画面へ送ります。
async function handleLikeClick(classmate: PublicClassmate) {
setLikeActionError(null);
try {
const result = await toggleLike(classmate);
if (result.type === "needsLogin") {
navigate(`/login?returnTo=${encodeURIComponent(`/g/${slug}/feed`)}`);
}
} catch (error) {
setLikeActionError(getErrorMessage(error));
}
}
連続クリックについては、フロントエンドでは対象投稿だけ disabled にします。
それでも二重リクエストは起こり得るので、DBの unique index と onConflictDoNothing() で守っています。
未いいね状態での DELETE は、エラーにせず現在状態を返します。
影響範囲
この変更は、DB、API、フロントエンドの3つに影響しました。
DBでは、classmate_likes テーブルと index が増えました。
APIでは、投稿一覧のレスポンスに likeCount と likedByMe を追加しています。
さらに、いいね追加と取り消しのエンドポイントを新設し、フロントエンドでは PublicClassmate の型、APIクライアント、usecase、一覧状態管理、カードUIを変更しています。
影響範囲が広いので、いいね処理をカード内だけで完結させないようにしました。
カードは表示とクリック通知に寄せ、APIの呼び分けは usecase、一覧データの更新は useClassmates に置いています。
この分け方にしておくと、あとからコメントいいね、通知、いいねした人一覧を追加するときにも考え方を使い回しやすくなります。
まとめ
いいね機能は、見た目だけなら小さなボタンですが、実装ではDB、API、認証、CSRF、UI状態、アクセシビリティまで関係します。
今回意識したのは、いいねを単なる数ではなく「ユーザーと投稿の関係」として扱うことでした。
そのため、like_count だけではなく classmate_likes テーブルを作り、user_id と classmate_id の unique index で重複を防いでいます。
ボタンの実装やクリック時の見た目を変えるだけなら簡単ですが、簡単な機能でも考えることが多いんだなと学びがありました。