SupabaseのRealtimeを使ってクイズアプリを作ってみた

## はじめに

この記事はHRBrain Advent Calendar 2022カレンダーの24日目の記事です。

https://qiita.com/advent-calendar/2022/hrbrain

こんにちは、HRBrainでバックエンドエンジニアしている田部です。

社内イベントの企画としてクイズ大会を行うためにクイズアプリを実装したのですが、その際にSupabaseのRealtimeを使ってみたので紹介したいと思います。

## 使用した技術
- Next.js + TypeScript
    - @supabase/supabase-js
- Supabase

## Supabaseとは

そもそもSupabaseとは、データベース・認証・ストレージなどバックエンドの機能を提供しているBaaS（Backend as a Service）です。

https://supabase.com/

Firebaseと似ているサービスですが、データベースとしてRDBであるPostgreSQLを利用している点が大きな違いです。

## Supabaseが提供するRealtime
クイズアプリでSupabaseを採用した理由は、Supabaseが提供するRealtimeの機能を使ってみたかったためです。

管理画面のクイズ問題がリアルタイムで回答者の画面と同期して表示されるような、ライブ感のある回答画面を作ってみたかったのでSupabaseを採用しました。

このRealtimeは、PostgreSQLに組み込まれたレプリケーション機能を使ってデータベースの変更をポーリングしていて、WebSocket経由でクライアントと通信しています。

データベースの変更が加わったタイミングで何かしらのアクションを起こすように登録することができるので、クイズアプリでこの機能を使ってみました。

## Realtimeの使い方

今回は`current_question_positions`というテーブルの変更をリアルタイムで検知するよう設定します。

`current_question_positions`テーブルは「今何問目か」のデータで、ここに変更が加わる度に回答画面でそれを検知して、画面に反映させるようにします。

**1\. まずブラウザのSupabaseの設定画面でRealtimeの設定をオンにします。**

サイドバー＞Database＞Tablesと進み、リアルタイムに検知したいDBのテーブルの編集ボタンを押します。
![supabase_screenshot.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507070/65223f42-4157-3836-4b1d-77d9f5948287.png)
Enable Realtimeを選択します。
![supabase_screenshot_realtime.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507070/1221c9b5-a63d-252b-fbef-698d3ade8693.png)
※今回はアプリを利用するのは1回という前提のため、RLS（Row Level Security）の設定はしていませんが、公開するアプリ等で利用する際はこの設定を有効化するよう推奨されています。

**2\. supabase-jsで実装する**

実際にsupabase-jsでRealtimeの機能を実装します。変更を検知する箇所のコードは以下のようになります。

あらかじめデータベースに接続するためにSupabaseのclientを作成してください。
```jsx
export const supabase = createClient(supabaseUrl, supabaseAnonKey)
const [questionId, setCurrentQuestionId] = useState<number | undefined>(undefined)

supabase
    .channel("current_question_positions")
    .on(
        "postgres_changes",
        {
            event: "UPDATE",
            schema: "public",
            table: "current_question_positions",
        },
        (payload) => {
            // 変更後のデータに対しての処理を記載
            setCurrentQuestionId(payload.new.current_question_id)
        }
    )
    .subscribe()
```
まず`channel()`で変更を検知したいテーブル名を指定します。ここでは`"current_question_positions"`とします。

次に、`on()`で変更を検知する設定をします。

`on()`の第一引数には、今回Realtimeでデータベースの変更を検知する機能を指定するため`"postgres_changes"`を入れます。

第二引数には、オブジェクトで詳細設定を指定します。
- event：検知したいデータベースの命令名（`INSERT`,`UPDATE`,`DELETE`,`*`のいずれか）
- schema：データベースのスキーマ
- table：変更を検知したいテーブル名

第三引数は、変更を検知したときに実行したい処理を指定します。`event`が`UPDATE`か`DELETE`の場合、変更後のデータを`new`として受け取れます。

最後に`subscribe()`を呼んで完了です。

変更の検知を終了させる場合は、`unsubscribe()`を呼びます。
```jsx
supabase.channel("current_question_positions").unsubscribe()
```
参考：[https://supabase.com/docs/guides/realtime/postgres-changes](https://supabase.com/docs/guides/realtime/postgres-changes)

## クイズの実装
クイズアプリでは管理画面と回答画面の2つを実装します。

説明用のコードは実際のコードを加工して記載しています。

### 回答画面
コードは以下の通りです。
```jsx
const ANSWER_VALUES = [1, 2, 3, 4] as const
type ANSWER_VALUES = typeof ANSWER_VALUES[number]

export default function AnswerScreen() {
    const [selectedValue, setSelectedValue] = useState<ANSWER_VALUES | undefined>(undefined)
    const [questionId, setCurrentQuestionId] = useState<number | undefined>(undefined)

    useEffect(() => {
        ;(async () => {
            const currentQuestionId = await getCurrentQuestionId()
            setCurrentQuestionId(currentQuestionId)
        })()
    }, [])

    useEffect(() => {
        const positionListener = getQuestionPositionChannel()

        positionListener
            .on(
                "postgres_changes",
                {
                    event: "UPDATE",
                    schema: "public",
                    table: "current_question_positions",
                },
                (payload) => {
                    setCurrentQuestionId(payload.new.current_question_id)
                }
            )
            .subscribe()
        return () => {
            positionListener.unsubscribe()
        }
    }, [])

    const onClickValue = (value: ANSWER_VALUES) => {
        setSelectedValue(value)
    }

    const onClickSubmit = async () => {
        // 回答データを新規作成
        await postAnswer({
            questionId: questionId,
            emailAddress: me.email,
            answerValue: selectedValue,
        })
    }

// 〜省略〜

    return (
        <main>
            <div>{questionId}問目</div>
            <div>
                {ANSWER_VALUES.map((value) => (
                    <button
                        key={value}
                        onClick={(e) => {
                            e.preventDefault()
                            onClickValue(value)
                        }}
                    >
                        {toStr(value)}
                    </button>
                ))}
            </div>
            <Button
                type="button"
                onClick={(e) => {
                    e.preventDefault()
                    onClickSubmit()
                }}
            >
                送信
            </Button>
        </main>
    )
}

```
回答画面では、四択の中から選択された値を`postAnswer()`に渡してクエリを実行し、回答データを保存しています。

さきほどのRealtimeの処理は`useEffect`内で行っており、コンポーネント作成時に`current_question_positions`の`UPDATE`に対して変更の監視を登録しています。

また`return`で`unsubscribe()`を呼んでいて、アンマウント時に変更監視の登録を解除するようにしています。
### 管理画面
以下のコードで`UPDATE`のクエリを実行したときに、回答画面で変更が検知されます。
```jsx
export const supabase = createClient(supabaseUrl, supabaseAnonKey)

const [questionId, setCurrentQuestionId] = useState<number | undefined>(undefined)

export const updateQuestionPosition = async (newQuestionId: number) => {
    await supabase
        .from("current_question_positions")
        .update({
            id: 1,
            current_question_id: newQuestionId,
        })
}

// 次の問題へ移る
const onClickNext = async () => {
    if (!questionId) return
    const v = questionId + 1
    await updateQuestionPosition(v)
    setCurrentQuestionId(v)
}

// 〜省略〜

```
### 実際の画面
クイズアプリの実際の画面はこのようになります。

![画面収録_2022-12-21_16_58_51のコピー_AdobeExpress.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/507070/5122719c-a736-a3de-51ef-8d12167652b5.gif)

## 最後に
今回Supabaseを利用してクイズアプリを作成しましたが、クイズの問題はスライドで映すという運用をしているため、回答画面上で見ることはできません。

しかし、最小のコードでクイズアプリとして成り立たせることができたので、Supabaseの便利さを実感できました。

このクイズアプリを実装し弊社でクイズ大会というイベントを企画できたので、とても満足しています。

弊社HRBrainではエンジニアを募集しています。興味がある方はぜひ見てみてください。

https://www.hrbrain.co.jp/recruit