Webのあれこれ

アイキャッチアイコンSupabaseのリアルタイムリスナーを使ってToDoアプリ作ってみた

カテゴリ: Supabase

  • 作成日:2023/03/26

目次

    タイトルの通り、Supabaseのリアルタイムリスナーを使ってToDoアプリを作成してみました。Next.jsで作成しています。
    Supabase今後ますますシェアを伸ばしていくと予想されるBaasの1つです。
    ちなみに読み方は「スーパベース」らしい。スパベースだと思ってた。。

    今回は、Supabaseとは何か?の説明は省きます。
    公式ドキュメントや、Discordが充実していますのでぜひ参考にしてみてください!

    背景

    なぜSupabaseを使用したかというと、
    ・FirebaseのCloud FirestoreはNoSQLなので、NoSQLを卒業して(?)RDBに慣れたい
    ・GUIで操作も可能なので、テーブル設計から徐々にPostgreSQLも触れるようになりたい
    ・Next.jsでの開発が主なので、得意なBaaSを持っておいてサクッとDB周りを開発してフロントに専念したい
    などの理由があるからです。

    ちなみに、PlanetScaleも少しかじりましたが、自分にはまだ早いようでした🥲
    Node.js周りも学習してから再度チャレンジ予定です。

    Supabase側の設定

    SupabaseはGUIで直感的にDBの作成・操作をすることが可能です。
    管理画面の操作についてはたくさん記事が出回っているので詳しい設定方法は割愛します。

    Supabaseの管理画面。RLSが無効になっていると警告が出ている。

    RLSを有効にすることで、特定のユーザーのみに閲覧や編集を制限することができますが、今回は自分1人で作業するため無効にしています。
    (デフォルトで有効になってて気づくのにめっちゃ時間かかった;;)

    ただ、Webアプリを一般公開する際はポリシーを登録してRLSを有効にしましょう。

    DBは以下のようにしました。
    データベース名:sample データベースは、id / created_at / text / isDone
    IDはuuid、作成日時は現在時刻が自動的に入るようにしています。

    実装コード

    DBは簡単に作成できるため、さっそくコードを書いていきます。
    作業環境としては以下の通りになります。

    作業環境

    root/
    ├ components/
    │     └ TodoItem.tsx
    ├ pages/
    │  └ index.tsx
    └ lib/
    │ ├ supabase.ts // 初期化
    │ └ supabaseFunc.ts  // 追加・削除・更新等の関数


    実装例

    まずは、パッケージをインストールします

    // npm
    npm install @supabase/supabase-js
    
    // yarn
    yarn add @supabase/supabase-js


    環境変数も設定します。

    NEXT_PUBLIC_SUPABASE_URL=https://*****************.supabase.co
    NEXT_PUBLIC_SUPABASE_API_KEY=*****************


    詳細は公式ドキュメントをご確認ください。

    次にsupabase.tsで初期化をします。

    supabase.ts

    import { createClient } from "@supabase/supabase-js";
    
    export type Database = {
      id?: number;
      text: string;
      createdAt?: string;
      isDone: boolean;
    };
    
    const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || "";
    const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_API_KEY || "";
    
    const supabase = createClient<Database>(supabaseUrl, supabaseAnonKey);
    
    export default supabase;


    先ほど、DBで設計した4つの要素を取得します。

    最初の基本的な環境構築などはShumpeiさんのこちらの記事が参考になりました!多謝!!

    次に、supabaseFunc.ts で必要な関数をまとめます。
    今回は、マウント時にDBの全データを取得し、ステートで管理して表示させます。
    変更があった場合は、ステートで差分のみ変更するようにしています。
    (毎度、DBから全データを取得すると通信量が多くなってしまうので)

    必要な関数としては、以下の4つです。
    ・DBから全データを取得する関数(マウント時)
    ・新しくToDoを追加する関数
    ・ToDoを削除する関数
    ・ToDoのチェックを書き換える関数

    supabaseFunc.ts

    import supabase from "./supabase";
    import { Database } from "./supabase";
    
    export const TABLE_NAME = "sample";
    
    // データの取得
    export const fetchDatabase = async () => {
      try {
        const { data, error } = await supabase.from(TABLE_NAME).select("*").order("createdAt"); // 全てのテーブルデータを日付順でソート
        if (error) throw new Error(error.message);
        return data;
      } catch (error) {
        console.error(error);
      }
    };
    
    // データの追加
    export const addSupabaseData = async ({ id, text, isDone }: Database) => {
      try {
        await supabase.from(TABLE_NAME).insert({ id, text, isDone }); // 新しく行を追加
      } catch (error) {
        console.error(error);
      }
    };
    
    // データの削除
    export const removeSupabaseData = async (taskId: number) => {
      try {
        await supabase.from(TABLE_NAME).delete().match({ id: taskId });  // 指定したIDの行を削除
      } catch (error) {
        console.error(error);
      }
    };
    
    // アップデート(チェックボックス)
    export const updateSupabaseData = async (taskId: number, newStatus: boolean) => {
      try {
        await supabase.from(TABLE_NAME).update({ isDone: newStatus }).match({ id: taskId }); // 指定したIDのチェック状態を更新
      } catch (error) {
        console.error(error);
      }
    };


    1つずつ詳細な解説はしませんが、基本的にはテーブル名に、update()や、insert()などで変更・追加することができます。
    match()で、指定したIDの行を選択することが可能です。

    次に、index.tsx です。
    初回のデータ取得に必要なもののみ記述しています。

    index.tsx

    import supabase, { Database } from "@/lib/supabase";
    import { fetchDatabase } from "@/lib/supabaseFunc";
    import { useEffect, useState } from "react";
    
    export default function Index() {
      const [todoText, setTodoText] = useState<Database[]>([]); // ToDoリストの一覧
    
      useEffect(() => {
        (async () => {
          const todo = await fetchDatabase();
          setTodoText(todo as Database[]); // '{ [x: string]: any; }[] | null'
        })();
      }, []);
    
      return (・・・)
    }
         


    これでステートに必要なデータが入りました。
    DBも簡単にいじることができ、データの取得もとても簡単でした!.....しかしこれだけでは不十分です。
    理由は、ローカルのステート(表示側)も変更する必要があるからです。

    今は、マウント時に初回のデータがステートに入っているだけですので、更新や追加、削除もブラウザ側で適用しなければなりません。
    そこで、Supabaseが提供しているリアルタイムリスナーを使います!

    コードは以下になります。

      // リアルタイムデータ更新
      const fetchRealtimeData = () => {
        try {
          supabase
            .channel("table_postgres_changes") // 任意のチャンネル名
            .on(
              "postgres_changes", // ここは固定
              {
                event: "*", // "INSERT" | "DELETE" | "UPDATE"  条件指定が可能
                schema: "public",
                table: TABLE_NAME, // DBのテーブル名
              },
              (payload) => {
                // データ登録
                if (payload.eventType === "INSERT") {
                  const { createdAt, id, isDone, text } = payload.new;
                  setTodoText((todoText) => [...todoText, { createdAt, id, isDone, text }]);
                }
                // データ削除
                if (payload.eventType === "DELETE") {
                  setTodoText((todoText) => todoText.filter((todo) => todo.id !== payload.old.id));
                }
              }
            )
            .subscribe();
    
          // リスナーの解除
          return () => {
            supabase.channel("table_postgres_changes").unsubscribe();
          };
        } catch (error) {
          console.error(error);
        }
      };


    追加・削除・更新が起きると関数が実行されます。
    コールバック関数から、イベントタイプごとに処理を分けています。

    シンプルに追加であれば、ステートに追加、削除はステートから削除です。
    ただ、チェックボックスの更新については、ブラウザでチェックの切替変更→DB反映するので更新の"UPDATE"は省略しています。

    イメージしやすいように、追加("INSERT")した場合のログを表示させます。
    データを追加した場合のコンソール画面。イベントタイプが"INSERT"になっている。IDやテキスト、チェック状態の有無も表示されている

    newに新しく追加したものが入ります。
    削除の場合は、oldにデータが入ります。

    これらをステート側にも反映すればOKです。

    残りの、index.tsxTodoItem.tsxも一気に作成します。

    index.tsx

    import TodoItem from "@/components/TodoItem";
    import supabase, { Database } from "@/lib/supabase";
    import { fetchDatabase, removeSupabaseData, addSupabaseData, TABLE_NAME, updateSupabaseData } from "@/lib/supabaseFunc";
    import { useEffect, useState } from "react";
    
    
    export default function Index() {
      const [inputText, setInputText] = useState(""); // 入力テキスト
      const [todoText, setTodoText] = useState<Database[]>([]); // ToDoリストの一覧
    
      // 入力テキスト
      const onChangeInputText = (event: React.ChangeEvent<HTMLInputElement>) => setInputText(() => event.target.value);
    
      // ToDoの追加
      const onSubmitAddTodo = (event: React.FormEvent<HTMLFormElement>) => {
        event.preventDefault();
        if (inputText === "") return;
        addSupabaseData({ text: inputText, isDone: false }); // DBに追加
        setInputText(() => "");
      };
    
      // ToDoの削除
      const onClickRemoveTodo = (id: number) => {
        const isPublished = window.confirm("本当に削除してもいいですか?この操作は取り消せません。");
        if (!isPublished) return;
        removeSupabaseData(id);
      };
    
      // ToDoのチェック
      const onClickTodoCheck = (id: number, isDone: boolean) => {
        updateSupabaseData(id, isDone);
      };
    
      // リアルタイムデータ更新
      const fetchRealtimeData = () => {
        try {
          supabase
            .channel("table_postgres_changes") // 任意のチャンネル名
            .on(
              "postgres_changes", // ここは固定
              {
                event: "*", // "INSERT" | "DELETE" | "UPDATE"  条件指定が可能
                schema: "public",
                table: TABLE_NAME, // DBのテーブル名
              },
              (payload) => {
                // データ登録
                if (payload.eventType === "INSERT") {
                  const { createdAt, id, isDone, text } = payload.new;
                  setTodoText((todoText) => [...todoText, { createdAt, id, isDone, text }]);
                }
                // データ削除
                if (payload.eventType === "DELETE") {
                  setTodoText((todoText) => todoText.filter((todo) => todo.id !== payload.old.id));
                }
              }
            )
            .subscribe();
    
          // リスナーの解除
          return () => {
            supabase.channel("table_postgres_changes").unsubscribe();
          };
        } catch (error) {
          console.error(error);
        }
      };
    
      useEffect(() => {
        (async () => {
          const todo = await fetchDatabase();
          setTodoText(todo as Database[]); // '{ [x: string]: any; }[] | null'
        })();
        fetchRealtimeData();
      }, []);
    
      return (
        <>
          {/* 追加エリア */}
          <form onSubmit={onSubmitAddTodo}>
            <input type="text" name="todo" value={inputText} onChange={onChangeInputText} />
            <button type="submit" disabled={inputText === ""}>
              追加
            </button>
          </form>
    
          {/* 表示エリア */}
          {todoText ? (
            <ul>
              {todoText.map((item: Database) => (
                <TodoItem key={item.id} onClickRemoveTodo={onClickRemoveTodo} onClickTodoCheck={onClickTodoCheck} item={item} />
              ))}
            </ul>
          ) : (
            <p>ToDoリストを追加してください📝</p>
          )}
        </>
      );
    }
    
    
    


    先ほど作成した、追加・更新・削除の関数も呼べるようにしました。
    ToDoの各アイテムは、TodoItem.tsxに分けて記述しました。

    TodoItem.tsx

    import { Database } from "@/lib/supabase";
    import { useState } from "react";
    
    type Props = {
      item: Database;
      onClickRemoveTodo: (id: number) => void;
      onClickTodoCheck: (id: number, isDone: boolean) => void;
    };
    
    const TodoItem = ({ item, onClickRemoveTodo, onClickTodoCheck }: Props) => {
      const [isChecked, setIsChecked] = useState(item.isDone); // チェックボックスの状態
    
      return (
        <li>
          <input
            type="checkbox"
            checked={isChecked}
            onChange={() => {
              onClickTodoCheck(item.id!, !isChecked);
              setIsChecked(() => !isChecked);
            }}
          />
          {item.text}
          <button onClick={() => onClickRemoveTodo(item.id!)}>削除</button>
        </li>
      );
    };
    
    export default TodoItem;
    
    


    先述したように、チェックボックスはローカル→DBで反映させています。

    動作デモ

    スタイルを当てていないのですが、以下のような動作になります。
    リロードして問題なければOKです。



    ちなみに、Supabaseの管理画面では以下のように確認できます。
    supabaseの管理画面、データベース

    おわり

    いかがだったでしょうか。
    リアルタイムリスナーでデータの更新などする方法を解説しました。
    Cloud Firestoreでもリアルタイム機能はありますが、徐々にRDBに慣れたい方などは第一歩としてもSupabseいいと思います。
    勢いがあるサービスなので、今後も個人開発で使っていきたいと思います。

    どんどん開発・改善が進んでいるサービスなので、今後実案件で使えることを楽しみにしています🙌

    参考


    この記事へのコメント

    この記事にはまだコメントがありません。

    お気軽にコメント残してください📝

    © 2022 wadeenOpenMojiis licensed underCC BY-SA 4.0