🧩

React × Tiptap x Zod × Zustand で認証付き Markdown ブログを Supabase で構築してみた

2024/10/20に公開

最近までは Dart や Swift など、モバイル開発の言語を書いていたのですが、久しぶりに React を書いてみたくなりました。せっかくなので、今注目されているモダンなサードパーティーライブラリを活用して、簡単なアプリを作ってみることにしました。

はじめに

このアプリは、ひとつのサービスを本格的に作るというよりも、React や使っているライブラリの使い方を理解するために作りました。技術の組み合わせや、どんなふうに使えるかを試してみたくて、手を動かしてみた感じです。

なお、この記事はコードを細かく解説したり、ハンズオン形式でアプリを作れるようにするものではなく、要所だけを掻い摘んで説明していきます。

つくったもの

今回は「認証付き Markdown ブログ」を作ってみました。ログイン機能が備わっていて、ログイン後にブログの作成、編集、削除ができるシンプルなアプリです。

https://markdown-blog-roan.vercel.app/

主な機能としては、

  • 認証(サインアップ、ログイン、ログアウト)
  • Markdown をサポートした記事の作成・編集
  • 投稿の一覧表示・詳細表示・削除

があります。

また、今回作成したプロジェクトはこちらのリポジトリに置いています。

技術スタック

アプリで利用した技術スタックはこちらです。

  • React: UI を構築
  • Tailwind CSS: UI スタイリング
  • Zod: スキーマバリデーション
  • Zustand: 状態管理
  • TanStack Query: データフェッチとキャッシング
  • Tiptap: リッチテキストエディタ
  • Vite: 開発・ビルドツール
  • Supabase: 認証、データベース管理
  • Vercel: デプロイプラットフォーム

特に Tailwind CSS、Zod、Zustand、TanStack Query は今回使ってみたかったライブラリです。

https://tailwindcss.com/

https://zod.dev/

https://zustand.docs.pmnd.rs/getting-started/introduction

https://tanstack.com/

Node.js, npm の各バージョンは以下になります。

$ node -v
v20.18.0

$ npm -v
10.8.2

実装時の気付きと覚え書き

それでは色々と書いていきます。冒頭でも書いた通りに、この記事はアプリ構築の中で気付いたことやメモしておきたいことなど要所だけを(少し雑に!?)掻い摘んで説明していきます。

プロジェクトの作成

プロジェクトの作成は Vite を使って構築しました。
コマンド実行後に対話型インタフェースに移行します。

$ npm create vite@latest markdown-blog --template react-ts

Need to install the following packages:
create-vite@5.5.3
Ok to proceed? (y) y

> npx
> create-vite markdown-blog react-ts

✔ Select a framework: › React
✔ Select a variant: › TypeScript

この中で Select a variant: という項目を初めて見たので、これについては以下のアコーディオン内に簡単な説明を載せておきました。

各テンプレート(variant)の説明

プロジェクトで利用するテンプレートには以下の種類がありました。

Select a variant: › - Use arrow-keys. Return to submit.
❯   TypeScript
    TypeScript + SWC
    JavaScript
    JavaScript + SWC
    Remix ↗

それぞれ説明を載せておきます。

TypeScript

通常の TypeScript テンプレート。TypeScript の標準的な設定で React プロジェクトを構築します。

TypeScript + SWC

TypeScript テンプレートに加えて、SWC(Speedy Web Compiler)を使用します。SWC は、Rust で書かれた高速なコンパイラで、ビルドやトランスパイルの速度が速いのが特徴です。大規模プロジェクトや、ビルド速度を重視する場合に有利です。

JavaScript

JavaScript ベースのプロジェクトを作成します。

JavaScript + SWC

JavaScript プロジェクトに SWC を使ったテンプレートです。

Remix

Remix のテンプレートです。

ためしに起動してみる

プロジェクトの構築が終わったので、試しに(記念に?)アプリを立ち上げてみる儀式🔮。

$ cd markdown-blog && npm install && npm run dev

> markdown-blog@0.0.0 dev
> vite

Re-optimizing dependencies because lockfile has changed

  VITE v5.4.9  ready in 334 ms

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help


はろーわーるど!

ライブラリのインストール

今回利用したライブラリのインストール用コマンドはこちらです。

$ npm install tailwindcss \
    @tailwindcss/typography \
    zod \
    zustand \
    @tanstack/react-query \
    @tanstack/react-query-devtools \
    @supabase/supabase-js \
    @tiptap/react \
    @tiptap/starter-kit \
    @tiptap/extension-placeholder \
    autoprefixer \
    react-router-dom

autoprefixer は PostCSS が必要とするため入れています。また、ルーティング用に react-router-dom を入れています。

tsconfig.xxx.jsonとは?

プロジェクトが作成された直後は tsconfig.json の他に tsconfig.app.jsontsconfig.node.json というファイルが存在していました。これらはフロントエンド/バックエンドの設定を分けて管理するためのファイルです。分ける必要がないのであれば、単一の tsconfig.json だけで問題ないようです。

https://speakerdeck.com/uhyo/tsconfig-dot-jsonnozui-jin-noxin-ji-neng-huairupasubian

Tailwind CSS の初期化

$ npx tailwindcss init -p

https://v1.tailwindcss.com/docs/configuration#creating-your-configuration-file

Supabase CLI のインストール

https://supabase.com/docs/guides/local-development/cli/getting-started?queryGroups=platform&platform=macos

$ brew install supabase/tap/supabase
$ supabase --version
1.204.3

Supabase の Row-Level Security エラー

new row violates row-level security policy for table \"posts\""
[[Prototype]]

このエラーは、Supabaseで「Row-Level Security (RLS)」が有効になっているために発生します。Row-Level Security は、ユーザーごとにデータのアクセスや挿入を制御するための仕組みで、Supabase では、デフォルトで RLS が有効になっています。

開発中にとりあえず動作を優先したい場合は、Supabase の管理画面から RLS を一時的に無効化することで問題なく進めることができます。開発が進んだ後で、必要に応じて適切なアクセス権限を設定し、認可されたユーザーのみがデータにアクセス・操作できるようにポリシーを追加することもできます。

管理画面での操作

Insert のポリシーを追加する SQL の例

CREATE POLICY "Allow insert for authenticated users"
ON posts
FOR INSERT
USING (auth.uid() IS NOT NULL);

Zod

Zod は、TypeScript 向けのスキーマ宣言とデータ検証を行うためのライブラリです。フォームのデータや API からのレスポンスを検証するのに役立ちます。データの形状が厳密に保証されるため、予期しないエラーを未然に防ぐことができるスグレモノです。

https://zod.dev/

使い方の例

ブログ記事の投稿処理を参考にします。

src/schemas/postSchema.ts
import { z } from "zod";

export const PostFormSchema = z.object({
  title: z.string().min(1, "タイトルは必須です"),
  content: z.string().min(1, "コンテンツは必須です"),
});

export type PostFormInput = z.infer<typeof PostFormSchema>;
src/pages/Post.tsx
import { PostFormSchema } from "@/schemas/postSchema";

const handleSubmit = async () => {
    const validationResult = PostFormSchema.safeParse({ title, content });
    if (!validationResult.success) {
      console.error("バリデーションエラー:", validationResult.error.errors);
      return;
    }
    ...
  };

スキーマの生成は object メソッドを使用し、バリデーション時のパース処理は safeParse メソッドを使用します。パース処理後のパラメータの success にバリデーションの結果が格納されます。また、型を生成することも可能で、その際は infer メソッドを使用します。

HTTP リクエストのレスポンスの検証について

今回は HTTP リクエストのレスポンスの検証を zod では行っておらず、Supabase から提供される型(src/types/postTypes.ts)を直接使う(お行儀の良い?)方法にしています。

src/types/postTypes.ts
import { Database } from "@/types/supabase";

export type Post = Database["public"]["Tables"]["posts"]["Row"];

もしレスポンス検証をする際は以下のような形で型を生成して使う想定でいます(まだ試していない...🐶)。

src/schemas/postSchema.ts
type PostResponse = z.infer<typeof XxxSchema>

その他

また、調べてみると Zod は「React Hook Form」と組み合わせて使っている方が多いようです。それも別の機会に試して見たいと思います。

https://www.react-hook-form.com/

Zustand

Zustand は、軽量な状態管理ライブラリで、React コンポーネントで使う状態(ステート)をシンプルに管理できます。Redux や MobX といったライブラリが持つ冗長な設定やコードの多さを避け、必要最小限の API で柔軟に状態を管理できる点が魅力です。

https://zustand.docs.pmnd.rs/getting-started/introduction

使い方の例

今回はログイン認証の部分で使っています。

src/stores/authStore.ts
import { create } from "zustand";
import { persist } from "zustand/middleware";

type AuthState = {
  userId: string | null;
  setUserId: (id: string | null) => void;
  clearAuth: () => void;
};

export const useAuthStore = create<AuthState>()(
  persist(
    (set) => ({
      userId: null,
      setUserId: (id) => set({ userId: id }),
      clearAuth: () => set({ userId: null }),
    }),
    {
      name: "auth-storage",
    }
  )
);
src/components/AuthProvider.tsx
import { useEffect } from "react";
import { useAuthStore } from "@/stores/authStore";
import { useUserStore } from "@/stores/userStore";
import { supabase } from "@/lib/supabaseClient";

export const AuthProvider = ({ children }: { children: React.ReactNode }) => {
  const { userId, setUserId, clearAuth } = useAuthStore();
  const { setUser, fetchUser } = useUserStore();

  useEffect(() => {
    const getSession = async () => {
      try {
        const {
          data: { session },
          error,
        } = await supabase.auth.getSession();
        if (error) throw error;

        if (session) {
          setUserId(session.user.id);
          setUser(session.user);
        }
      } catch (error) {
        console.error("セッション取得エラー:", error);
        clearAuth();
      }
    };

    getSession();

    const { data: listener } = supabase.auth.onAuthStateChange(
      (_event, session) => {
        if (session) {
          setUserId(session.user.id);
          setUser(session.user);
        } else {
          clearAuth();
        }
      }
    );

    return () => {
      listener.subscription.unsubscribe();
    };
  }, [setUserId, setUser, clearAuth]);

  useEffect(() => {
    if (userId) {
      fetchUser();
    }
  }, [userId, fetchUser]);

  return <>{children}</>;
};
src/App.tsx
import { AuthProvider } from "@/components/AuthProvider";

export default function App() {
  return (
    <AuthProvider>
        ...
    </<AuthProvider>
  )
}

ログイン認証したユーザーの ID(userId)を、persist というステートを永続化するためのミドルウェアを使ってローカルストレージに保存しています。ユーザーのログインしているかどうかはこの値を基準にしています。そして AuthProvider でセッションの取得や認証状態の管理をしています。

ローカルステートでも使っていこうぜ!!

今回のアプリでは、モーダルやポップアップメニューの開閉の判定に状態管理を使っているのですが、ここは現状では useState を使っています。ここを Zustand に置き換えるとコードがよりシンプルになりそうです(↓のような感じ??)。

import create from 'zustand';

const useModalStore = create((set) => ({
  isOpen: false,
  openModal: () => set({ isOpen: true }),
  closeModal: () => set({ isOpen: false }),
}));

export default useModalStore;

Tanstack Query

TanStack Query は、サーバーサイドのデータフェッチ、キャッシング、同期を効率よく行うためのライブラリです。API からデータを取得し、状態を簡単に管理できます。特にリアクティブなアプリケーションや、キャッシュを活用して効率を上げたいときに便利です。

https://tanstack.com/

使い方の例

データフェッチのメソッドは useQuery で、パラメータとしてキャッシュのキー(queryKey)と実行したい処理(queryFn)を指定します。データ操作のメソッドは useMutation で、実行したい処理(mutationFn)やミューテーションが成功したり(onSuccess)失敗した(onError)際の処理などを指定できます。このあたりは他の非同期通信系のライブラリに似ていますね。

これがなかなか便利でした。データフェッチの話だと、React で従来的な useState/useEffect を使った処理が、useQuery だけで完結します。

変更前(useState, useEffect を使用)

import { useEffect, useState } from "react";

const [posts, setPosts] = useState<Post[]>([]);

useEffect(() => {
  const fetchPosts = async () => {
    const { data } = await supabase.from("posts").select("*");
    setPosts(data as Post[]);
  };

  if (user) {
    fetchPosts();
  }
}, [user]);

変更後(TanStack Query を使用)

import { useQuery } from "@tanstack/react-query";

const { data: posts, isLoading, isError } = useQuery(["posts"], async () => {
  const { data } = await supabase.from("posts").select("*");
  return data;
});

実装中に疑問に思ったこと

Tanstack Query を使っていると、キャッシュをどこまで活用するかが課題になります。特に、ログイン処理にもキャッシュを利用するべきかどうかという点です。また、Zustand を使って認証状態を管理しているため、その役割分担についても考慮する必要があります。

その点について、GPT 先生にアドバイスをいただきました。

ログイン処理に関して、Tanstack Queryはデータのキャッシュや非同期通信の管理に優れていますが、認証処理に直接使うのはあまり一般的ではありません。通常、認証に関してはZustandなどの状態管理を使うのが適しています。理由として、ログイン処理は通常一度行われ、ユーザー情報を取得してその後はアプリ全体でその情報を使いまわす形になるためです。
ログイン時にはSupabaseの認証機能を使って、ZustandやlocalStorageにその結果(ユーザー情報)を保持するのが適切です。Tanstack Queryは、認証後にユーザー固有のデータをフェッチしたり、CRUD操作を行う際に活用するのが良いでしょう。

Tiptap

Tiptap はモダンな WYSIWYG(リッチテキスト)エディタで、React と相性が良く、カスタマイズ性が高いのが特長です。Markdown や HTML にも対応しており、複雑なリッチコンテンツの編集や保存を行うアプリケーションに最適です。

https://tiptap.dev/

使い方の例

src/components/Editor.tsx
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import EditorMenu from "@/components/EditorMenu";

const editor = useEditor({
  extensions: [
    StarterKit,
    Placeholder.configure({
      placeholder: "内容を入力してください",
      emptyEditorClass: "/* エディタが空の状態のときに適用される CSS クラス */",
    }),
  ],
  editorProps: {
    attributes: {
      class: "/* エディタの基本的な CSS クラス */",
    },
  },
  onUpdate: ({ editor }) => {
    setContent(editor.getHTML());
  },
});

return (
  <>
    <label
      className="block text-lg font-medium mb-2"
      htmlFor={editorId}
      onClick={handleLabelClick}
    >
      コンテンツ
    </label>
    <EditorContent editor={editor} id={editorId} />
    <EditorMenu editor={editor} />
  </>
);

今回の装飾機能は太字斜体取り消し線の3つを実装しています。

テキストの装飾は以下のように対応する装飾コマンド(下の xxx() の部分)に対して run() を実行します。各コマンドはこちらに載っています。

editor.chain().focus().xxx().run()

また、装飾用の UI はライブラリに予め定義されている BubbleMenu クラスを利用しています。今回は自作しましたが、公式から便利な拡張機能も提供されています。

現状の課題と今後の改善点について

現在、基本的な機能は実装できていますが、まだいくつかの改善が必要な箇所があります。今後の開発では、アーキテクチャやコードの品質向上、機能追加に焦点を当てていきたいと考えています。以下に、今後取り組むべき主な課題と機能のリストをまとめてみました。

  • アーキテクチャ・ディレクトリ構成を検討する
  • API 通信が冗長なのでよくしたい
  • エラーハンドリングをちゃんとする
    • 今は型推論に頼っていて、catch したエラー内容をそのまま表示している
  • 言語ファイルを作って固定文字列を管理したい
  • プロフィールページを作ってユーザー情報を更新できるようにする
    • メールアドレスやパスワードの変更、アイコンを設定できたりなど
  • 投稿検索機能
  • 投稿一覧のページング機能

おわりに

久しぶりに React を使って、モダンなライブラリを組み合わせてアプリを作ってみることで、多くの新しい発見がありました。React のシンプルさや、サードパーティー製ライブラリとの組み合わせの良さを改めて実感することができました。

これまでにもいくつかアプリを作ってきましたが、最近はその頻度が減っていました。久しぶりにアプリを作ってみて、「やっぱりアプリ開発は楽しいな」と改めて感じました。やっぱり手を動かして、思い描いたものが形になる瞬間は、開発者としての喜びの一つですね。

今回のアプリは技術スタックを理解するためのものなので、まだ改善すべき点は多くありますが、時間を見つけて出てきた課題に取り組めたらと思います。

この記事が、同じようにモダンな技術に触れている方の参考になれば幸いです。

へばまた〜!

スペシャルサンクスな記事

今回のアプリ開発でお世話になった記事やサービスです。どうもありがとうございました!! 🙌

https://zenn.dev/sc30gsw/articles/56e07707a4f55b
https://tech-blog.rakus.co.jp/entry/20240112/zod
https://zenn.dev/taisei_13046/books/133e9995b6aadf
https://tailwindcss.com/docs/installation
https://lorem.sabigara.com/
https://www.canva.com/

Discussion