2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React + Hono + Cloudflare D1 でいいねボタンを実装する

2
Posted at

近況共有アプリの投稿へ「いいね」できる機能を追加しました。

一覧画面のカード下部には、もともと仮表示として いいね 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

返す投稿データに likeCountlikedByMe を追加します。

{
  "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),
    ),
  )

ここで MapSet にしておくと、レスポンス作成時に扱いやすいです。

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の責務を分けておくと、あとから通知やいいねした人一覧を追加しやすくなります。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?