掲示板アプリで、ユーザーが新しい返信を見逃さないよう「未読管理機能」と「通知UI」を実装しました。

正直なところ、最初は既読管理の仕様をどう設計するかで少し悩みました。最終的にSupabaseのRLSとRealtimeを組み合わせることで、シンプルかつリアルタイムに更新される未読管理が実現できました。

個人的に、React Contextでの状態管理がシンプルで気に入っています。また、実際に使ってみると、未読管理の重要性を改めて実感しました。

この記事では、データベース設計からReact Contextによる状態管理、Realtime購読による自動更新まで、実装の全貌を解説します。

こんな人におすすめ

この記事は以下のような方に役立ちます。

  • Supabaseで未読管理機能を実装したい方
  • PostgreSQLのRLS(Row Level Security)を活用したい方
  • Supabase Realtimeでリアルタイム更新を実装したい方
  • React Contextでグローバルな状態管理を検討している方
  • 未読管理のデータベース設計に悩んでいる方

目次

実装の概要

掲示板アプリにおいて、ユーザーが新しい返信を見逃さないよう、未読管理機能と通知UIを実装しました。

主な実装内容:

  • DBスキーマ: user_read_status テーブル + RPC関数
  • API: 既読マーク / 未読数取得 / トピック一覧に未読情報追加
  • UI: 未読バッジ / ハイライト / 自動既読処理
  • Realtime: 新しい返信があったら未読数を自動更新

アーキテクチャ設計

未読管理機能のアーキテクチャは、以下の3層構造で設計しました。

データベース層(Supabase/PostgreSQL):

  • user_read_status テーブルでユーザーごとの既読状態を管理
  • RLS(Row Level Security)でセキュリティを確保
  • RPC関数で複雑な未読判定ロジックを実装

API層(Next.js API Routes):

  • 未読数取得エンドポイント
  • 既読マーク付けエンドポイント
  • Supabaseクライアントを通じたデータアクセス

プレゼンテーション層(React):

  • React Contextでグローバルな未読状態を管理
  • Supabase Realtimeで新規返信を監視し自動更新
  • 未読バッジコンポーネントで視覚的に通知

この3層構造により、関心の分離と保守性の向上を実現しています。

アーキテクチャ図

graph LR
    subgraph プレゼンテーション層
        A[React Context]
        B[未読バッジ<br/>UnreadBadge]
        C[Realtime購読]
    end

    subgraph API層
        D[未読数取得API<br/>/api/notifications/unread-count]
        E[既読マークAPI<br/>/api/topics/:id/mark-read]
    end

    subgraph データベース層
        F[(Supabase<br/>PostgreSQL)]
        G[user_read_status<br/>テーブル]
        H[get_unread_topic_count<br/>RPC関数]
        I[RLSポリシー]
    end

    A --> D
    A --> E
    B --> A
    C --> F

    D --> F
    E --> F
    F --> G
    F --> H
    F --> I

    style A fill:#e1f5fe
    style B fill:#fff3e0
    style C fill:#f3e5f5
    style F fill:#e8f5e9
    style G fill:#c8e6c9
    style H fill:#c8e6c9
    style I fill:#ffcdd2

アーキテクチャのポイント:

  • プレゼンテーション層はReact Contextで状態管理し、Realtimeで自動更新
  • API層はNext.js API RoutesでSupabaseへのアクセスを抽象化
  • データベース層はRLSでセキュリティを確保し、RPC関数で複雑なロジックを実装

データベース設計と実装

user_read_statusテーブルの作成

まず、ユーザーの既読状態を管理するテーブルを作成します。

-- user_read_status テーブル作成
CREATE TABLE IF NOT EXISTS "public"."user_read_status" (
    "id" "uuid" DEFAULT "gen_random_uuid"() NOT NULL,
    "user_id" "text" NOT NULL,
    "topic_id" "uuid" NOT NULL,
    "last_read_at" timestamp with time zone DEFAULT "now"() NOT NULL,
    "last_reply_count" integer DEFAULT 0 NOT NULL,
    "created_at" timestamp with time zone DEFAULT "now"() NOT NULL,
    "updated_at" timestamp with time zone DEFAULT "now"() NOT NULL,
    CONSTRAINT "user_read_status_pkey" PRIMARY KEY ("id"),
    CONSTRAINT "user_read_status_user_id_fkey" FOREIGN KEY ("user_id")
        REFERENCES "public"."users"("id") ON DELETE CASCADE,
    CONSTRAINT "user_read_status_topic_id_fkey" FOREIGN KEY ("topic_id")
        REFERENCES "public"."topics"("id") ON DELETE CASCADE
);

テーブル設計のポイント:

  • user_idtopic_id の複合ユニーク制約(アプリ側で実装)
  • last_read_at で最後に既読にした日時を管理
  • last_reply_count で既読時の返信数を保持(増分判定用)
  • 外部キー制約でCASCADE削除を設定(トピック/ユーザー削除時に連動削除)

RLSポリシーの設定

セキュリティのため、RLS(Row Level Security)を有効にします。

-- RLSポリシー(自分のレコードのみ閲覧・操作可能)
ALTER TABLE "public"."user_read_status" ENABLE ROW LEVEL SECURITY;

CREATE POLICY "user_read_status_select_policy" ON "public"."user_read_status"
    FOR SELECT TO "authenticated" USING (auth.uid()::text = user_id);

RLSのメリット:

  • データベースレベルでセキュリティを保証
  • アプリケーション側のバグによる情報漏洩を防止
  • 認証済みユーザーが自分のレコードのみアクセス可能

未読数取得RPC関数の実装

未読数を取得するRPC関数を作成します。

-- 未読数取得RPC関数
CREATE OR REPLACE FUNCTION "public"."get_unread_topic_count"("p_user_id" "text")
RETURNS integer
LANGUAGE "sql"
SECURITY DEFINER
AS $$
  SELECT COUNT(DISTINCT t.id)::integer
  FROM public.topics t
  LEFT JOIN public.user_read_status urs
    ON t.id = urs.topic_id AND urs.user_id = p_user_id
  WHERE
    EXISTS (SELECT 1 FROM public.replies r WHERE r.topic_id = t.id)
    AND (
      urs.id IS NULL
      OR EXISTS (
        SELECT 1 FROM public.replies r
        WHERE r.topic_id = t.id AND r.created_at > urs.last_read_at
      )
    )
    AND (
      t.author_id = p_user_id
      OR EXISTS (
        SELECT 1 FROM public.replies r2
        WHERE r2.topic_id = t.id AND r2.author_id = p_user_id
      )
    );
$$;

未読判定ロジック:

  1. 返信が存在するトピックのみ対象
  2. 以下の条件をすべて満たすトピックを未読としてカウント:
    • 自分が作成したトピック、または自分が返信したトピック
    • user_read_status レコードが存在しない(未訪問)
    • または last_read_at より後に新しい返信がある

未読判定のフローを図で確認します。

未読判定フロー図

flowchart TD
    A[トピック] --> B{返信が<br/>存在する?}
    B -->|いいえ| Z[未読対象外]
    B -->|はい| C{自分が関与<br/>している?}

    C -->|いいえ| Z
    C -->|はい| D{user_read_status<br/>レコードが<br/>存在する?}

    D -->|いいえ| E[未読]
    D -->|はい| F{last_read_atより<br/>後に新しい返信<br/>がある?}

    F -->|はい| E
    F -->|いいえ| G[既読]

    style E fill:#ffcdd2
    style G fill:#c8e6c9
    style Z fill:#e0e0e0

フローのポイント:

  • まず「返信が存在するか」でフィルタリング(返信がないトピックは通知不要)
  • 次に「自分が関与しているか」でフィルタリング(作成者または返信者)
  • 最後に「既読状態」を判定(未訪問または新しい返信があれば未読)

フロントエンドの実装

React Contextによる状態管理

React Contextで未読状態を管理し、Supabase Realtimeで新規返信を監視します。

export function NotificationProvider({ children }: { children: ReactNode }) {
  const [unreadCount, setUnreadCount] = useState(0);
  const { currentUser } = useAuth();

  const refreshUnreadCount = useCallback(async () => {
    if (!currentUser?.id) return;
    const response = await fetch(
      `/api/notifications/unread-count?userId=${encodeURIComponent(currentUser.id)}`
    );
    if (response.ok) {
      const data = await response.json();
      setUnreadCount(data.unreadCount ?? 0);
    }
  }, [currentUser?.id]);

  const markAsRead = useCallback(
    async (topicId: string, _replyCount: number) => {
      if (!currentUser?.id) return;
      const response = await fetch(`/api/topics/${topicId}/mark-read`, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ userId: currentUser.id }),
      });
      if (response.ok) await refreshUnreadCount();
    },
    [currentUser?.id, refreshUnreadCount]
  );

  // Realtime購読
  useEffect(() => {
    if (!currentUser?.id) return;
    const channel = supabase
      .channel('replies-for-notifications')
      .on('postgres_changes',
        { event: 'INSERT', schema: 'public', table: 'replies' },
        () => refreshUnreadCount()
      )
      .subscribe();
    return () => { supabase.removeChannel(channel); };
  }, [currentUser?.id, refreshUnreadCount]);

  // ...
}

実装のポイント:

  • useState で未読数を管理
  • useCallback で関数をメモ化(再レンダリング防止)
  • useEffect でRealtimeチャンネルを購読
  • クリーンアップ関数でチャンネルを解除(メモリリーク防止)

Realtimeによる自動更新のシーケンスを図で確認します。

Realtime自動更新のシーケンス図

sequenceDiagram
    participant U as ユーザーA
    participant U2 as ユーザーB
    participant R as React Context
    participant S as Supabase Realtime
    participant DB as PostgreSQL

    Note over R,DB: 初期化
    R->>DB: 未読数を取得
    DB-->>R: 未読数: 3

    Note over U2,DB: 新規返信投稿
    U2->>DB: 返信をINSERT
    DB->>S: INSERTイベント発火
    S->>R: postgres_changes通知
    R->>DB: 未読数を再取得
    DB-->>R: 未読数: 4
    R->>U: 未読バッジ更新(4)

    Note over R,S: クリーンアップ
    R->>S: チャンネル解除

Realtime自動更新のポイント:

  • ユーザーBが返信を投稿すると、PostgreSQLがINSERTイベントを発火
  • Supabase Realtimeがイベントを購読中のReact Contextに通知
  • React Contextが未読数を再取得してバッジを更新
  • ユーザーAはページをリロードせずにリアルタイムで通知を受け取れる

既読マーク処理のシーケンスも確認します。

既読マーク処理のシーケンス図

sequenceDiagram
    participant U as ユーザー
    participant R as React Context
    participant API as Next.js API
    participant DB as PostgreSQL

    U->>R: トピックを開く
    R->>API: mark-read POST
    API->>DB: user_read_statusをUPSERT
    DB-->>API: 成功
    API-->>R: レスポンス
    R->>DB: 未読数を再取得
    DB-->>R: 未読数: 2
    R->>U: 未読バッジ更新(2)

既読マーク処理のポイント:

  • トピックを開くと自動で既読マークAPIを呼び出し
  • データベースのuser_read_statusをUPSERT(存在すれば更新、なければ作成)
  • 未読数を再取得してバッジを更新

未読バッジコンポーネント

未読数を表示するバッジコンポーネントです。9より大きい場合は「9+」と表示します。

export function UnreadBadge({ count, className = '' }: UnreadBadgeProps) {
  if (count <= 0) return null;

  return (
    <span className={`inline-flex items-center justify-center min-w-[20px] h-5 px-1.5 text-xs font-bold text-white bg-red-500 rounded-full ${className}`}>
      {count > 9 ? '9+' : count}
    </span>
  );
}

UIのポイント:

  • 未読数が0の場合は非表示(nullを返却)
  • 9を超える場合は「9+」表示(UIの崩れ防止)
  • Tailwind CSSでスタイリング(赤背景の丸型バッジ)

未読判定ルールとビジネスロジック

未読判定のビジネスロジックを明確に定義します。

シナリオ 未読対象 理由
自分が作成したトピック(返信あり) 自分のトピックに新しい返信がある
自分が返信したトピック(新規返信あり) 関与したトピックに新しい返信がある
返信が0件のトピック 返信がないため通知不要
閲覧のみのトピック 関与していないトピックは対象外

このルールにより、ユーザーにとって意味のある通知のみを表示できます。

パフォーマンスの最適化

Gemini PRレビューで指摘を受けるまでは、パフォーマンス問題に気づいていませんでした。

別コミットで以下を対応:

SQLの最適化:

  • WITH句で自分が関与したトピックを先に絞り込む
  • インデックスの追加(user_read_status(user_id, topic_id) など)

Realtimeの最適化:

  • Debounce(2秒)を追加して頻繁な更新を抑制
  • チャンネル名を一意にして競合を防止

APIの最適化:

  • mark-read APIで全返信データ取得を避け、カウントのみ取得
  • レスポンスデータを最小限に削減

関連記事:Supabaseのクエリパフォーマンスを改善するテクニック(※内部リンク予定)

FAQ:よくある質問

Q1: 未読管理機能を実装するのにどのくらい時間がかかりますか?

A: 基本的な実装で1〜2日、テストと調整を含めて3〜5日程度です。データベース設計(半日)、フロントエンド実装(1日)、Realtime連携(半日)、テスト(1日)、微調整(1日)といった内訳になります。

Q2: RLSは必須ですか?

A: セキュリティの観点から強く推奨します。RLSがない場合、アプリケーション側のバグで他ユーザーの既読状態にアクセスされるリスクがあります。データベースレベルでアクセス制限をかけることで、より安全なシステムになります。

Q3: Realtimeはどのような場合に有効ですか?

A: 複数ユーザーが同時に利用するアプリケーションで有効です。掲示板、チャット、コラボレーションツールなど、リアルタイム性が求められる場面で活躍します。一方、個人利用のみのアプリではポーリングでも十分かもしれません。

Q4: 未読数が増え続ける問題が発生した場合、どう対処しますか?

A: 以下の対策が有効です:

  • 定期的に未読数を0にリセットする機能
  • 未読数の上限を設定(例:99+表示)
  • 古い未読をアーカイブする機能
  • 通知オフ機能の実装

Q5: スマートフォンでも動作しますか?

A: はい、レスポンシブデザインで実装すれば動作します。RealtimeはWebSocketを使用するため、モバイルネットワークでも安定して動作します。ただし、バッテリー消費に注意が必要です。

まとめ

Next.jsとSupabaseで未読管理機能を実装しました。DBスキーマ設計からReact Contextによる状態管理、Realtime購読による自動更新まで、一連の実装を紹介しました。

個人的に、この実装を通じて学んだことは大きかったです。特に、SupabaseのRLSとRealtimeを組み合わせることで、フロントエンド側の実装をシンプルに保ちながら、リアルタイム性とセキュリティの両立ができることを実感しました。

同じような未読管理機能の実装を検討している方の参考になれば幸いです。