近況共有アプリの投稿へ「いいね」できる機能を追加しました。
一覧画面のカード下部には、もともと仮表示として いいね 0 を置いていましたが、今回はこれを実データに置き換えて、ログイン中のユーザーがいいねを追加・取り消しできるようにします。
この記事では、React + Hono + Cloudflare D1 + Drizzle ORM で、いいねボタンをどう実装したかをまとめます。
作るもの
要件は次のようにしました。
- 投稿一覧でいいね数を表示する
- ログイン中のユーザーは他人の投稿にいいねできる
- いいね済みの投稿は見た目を変える
- もう一度押すと取り消せる
- 1ユーザーは1投稿に1回だけいいねできる
- 未ログインで押したらログイン画面に送る
- 自分の投稿にはいいねできない
- リアルタイム反映はしない
最後の「リアルタイム反映はしない」は、他の人が押したいいねをWebSocketなどで即時配信しない、という意味です。
押した本人の画面はAPIレスポンスで更新します。
他の人の画面は、再読み込みや再取得のタイミングで反映されればOKとしました。
DB設計
いいね数だけを classmates テーブルに持たせる方法もありますが、それだと誰がいいねを押したかが分かりません。
今回は以下を満たしたかったので、いいね専用のテーブルを追加しました。
- 同じユーザーの重複いいねを防ぐ
- 自分がいいね済みか判定する
- 取り消しできる
- 将来、いいねした人一覧や通知に拡張できる
追加したテーブルは 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),
],
)
ポイントはこの unique index です。
uniqueIndex('classmate_likes_classmate_user_unique').on(
table.classmateId,
table.userId,
)
これで、同じユーザーが同じ投稿に複数回いいねすることをDB側でも防げます。
UIで連打を防ぐだけでは不十分で、APIが二重に呼ばれたり、別クライアントから直接叩かれたりしても、DB制約が最後の砦になります。
一覧APIに likeCount と likedByMe を追加する
公開投稿一覧は、既存のエンドポイントを拡張しました。
GET /api/groups/:slug/classmates
返す投稿データに likeCount と likedByMe を追加します。
{
"classmates": [
{
"id": 123,
"name": "山田 太一",
"currentLocation": "福岡市",
"job": "小学校の先生",
"comment": "最近の近況",
"snsUrl": "",
"createdAt": "2026-06-08T09:30:00.000Z",
"avatarUrl": null,
"likeCount": 3,
"likedByMe": false
}
]
}
likeCount は全員に見せます。
likedByMe は、ログイン中のユーザーがその投稿にいいね済みかどうかです。
未ログインの場合は常に false にします。
実装では、まず既存の投稿一覧を取得します。
const rows = await db
.select({
id: classmates.id,
name: classmates.name,
currentLocation: classmates.currentLocation,
job: classmates.job,
comment: classmates.comment,
snsUrl: classmates.snsUrl,
createdAt: classmates.createdAt,
avatarUpdatedAt: classmateAvatars.updatedAt,
})
.from(classmates)
.leftJoin(classmateAvatars, eq(classmateAvatars.classmateId, classmates.id))
.where(
and(
eq(classmates.groupId, group.id),
eq(classmates.status, 'published'),
eq(classmates.visibility, 'public'),
),
)
.orderBy(desc(classmates.createdAt))
その後、投稿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),
})),
})
いいね数の集計は 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 にしておくと、レスポンス作成時に扱いやすいです。
const likeCountByClassmateId = new Map<number, number>()
const likedClassmateIds = new Set<number>()
いいね追加API
いいね追加は次のエンドポイントにしました。
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 }
}
条件に合わない投稿は 404 にしています。
非公開・削除済み・別グループの投稿なども、いいね対象としては見つからない扱いにします。
重複いいねはエラーにしない
いいね追加では 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
}
}
フロントエンドの型を拡張する
一覧で使う PublicClassmate に、いいね情報を追加しました。
export type PublicClassmate = Pick<
Classmate,
'id' | 'name' | 'currentLocation' | 'job' | 'comment' | 'snsUrl' | 'createdAt'
> & {
avatarUrl: string | null
likeCount: number
likedByMe: boolean
}
APIから返るいいね操作結果も型にします。
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でも同じ仕組みに乗せています。
usecaseで追加と削除を切り替える
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 というhookに寄せています。
いいね後に一覧全体を再取得してもよいですが、今回はAPIが返した確定状態で該当投稿だけ更新しました。
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],
);
カードのいいねボタン
投稿カードでは、いいね済みかどうかで見た目を変えます。
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>
当初はアプリ全体のアクセントに合わせて緑系にしていました。
ただ、ハートのいいねは赤系のほうが直感的なので、いいね済み状態だけ rose 系にしています。
未いいね状態はグレーのままにしました。
自分の投稿にはいいねできない
フィードでは、自分の投稿かどうかを myClassmates から判定しています。
const myClassmateIds = useMemo(() => {
return new Set(myClassmates.map((classmate) => classmate.id));
}, [myClassmates]);
自分の投稿では、いいねボタンとしては押せない表示にしました。
{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>
)}
ただし、UIで押せなくするだけでは不十分です。
API側でも必ずチェックしています。
if (classmate.authorUserId === session.user.id) {
throw new HTTPException(400, { message: '自分の投稿にはいいねできません' })
}
未ログイン時の動き
未ログインでいいねボタンを押した場合、APIは 401 を返します。
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));
}
}
ログイン後に自動でいいねを再実行するところまでは、今回のMVPではやっていません。
まずはログイン後にフィードへ戻るところまでです。
まとめ
いいね機能は一見シンプルですが、実装すると考えることがいくつかあります。
- 誰が押したかを保存する
- 重複いいねをDB制約で防ぐ
- 一覧にいいね数と自分の状態を返す
- 追加と取り消しを冪等に近い形で扱う
- 未ログインや自分の投稿への操作をAPI側で止める
- フロントでは該当投稿だけ状態更新する
今回はリアルタイム反映や通知は入れず、通常のAPI更新だけに絞りました。
このくらいの機能でも、DB・API・UIの責務を分けておくと、あとから通知やいいねした人一覧を追加しやすくなります。