Thanks to visit codestin.com
Credit goes to github.com

Skip to content

react-spa-architecture vite typescript shadcn/ui tailwind fetch-api-http-client supabase-auth supabase-database supabase-edge-functions prisma drizzle postgres tanstack-query tRPC zod zustand node deno hono vitest playwright redis severless

Notifications You must be signed in to change notification settings

k-gitest/react-spa-architecture

Repository files navigation

📚 目次

概要

拡張性と保守性を重視して設計されたReactベースのSPAメモアプリケーション

目的

  • 拡張的で効率的な運用保守ができるアーキテクチャと責務分離のレイヤード設計を目指す
  • shadcn/uiのUIを使用してパーツ別カスタムUIコンポーネントを作成して再利用可能にする
  • supabase, tanstack-query, tRPCの各クライアントを使用してカスタムフックを作成して再利用可能にする
  • supabaseをバックエンドとして使用する(auth・database postgres・edge functions)
  • prismaをデータベース管理として使用する(DBスキーマ・マイグレーション)
  • edgeのORMにprisma・drizzleを使用する(トランザクション・リレーション)
  • 複数api通信とedge環境でのORMによるパフォーマンス比較検証、部分的な分離によるアーキテクチャ設計の検証

開発環境

  • react 18.2.0
  • react-router-dom 7.2.0
  • react-hook-form 7.54.2
  • react-query 5.68.0
  • vite 6.1.1
  • vitest 3.0.6
  • playwright 1.53.0
  • msw 2.10.2
  • playwright-msw 3.0.1
  • trpc 11.0.0
  • typescript 5.7.0
  • zod 3.24.2
  • zustand 5.0.3
  • shadcn/ui
  • tailwindcss 3.4.13
  • react-helmet-async 2.0.5
  • upstash redis 1.34.3
  • prisma 6.5.0
  • drizzle-orm 0.32.2
  • supabase 2.19.7
  • deno 2.2.5
  • hono 4.0.0
  • node 20.18.1

ディレクトリ構造

/
├── public
├── src
│    ├── components ...共通コンポーネントディレクトリ
│    │    ├── form ...フォームパーツコンポーネント
│    │    ├── layout ...レイアウトコンポーネント
│    │    ├── ui ...shadcn/uiコンポーネント
│    │    ├── mode-toggle.tsx ...テーマ切替
│    │    ├── file-thumbnail.tsx ...選択ファイルサムネイル表示
│    │    ├── file-uploader.tsx ...ファイル選択
│    │    ├── variant-toggle.tsx ...api通信切替
│    │    ├── with-behavior-variant.tsx ...コンポーネント切替
│    │    ├── responsive-dialog.tsx ...ドロワー/ダイアログ
│    │    └── async-boundary.tsx ...error-boundary/suspenseラッパー
│    ├── features ...機能別ディレクトリ
│    │    ├── auth
│    │    ├── account
│    │    ├── memo
│    │    ├── profile
│    │    │     ├── components ...機能別コンポーネント
│    │    │     ├── hooks ...機能別フックス
│    │    │     ├── schemas ...機能別スキーマ
│    │    │     ├── services ...機能別サービス
│    │    │     └── types ...機能別型
│    │    └── settings
│    ├── lib
│    │    ├── auth.ts ...認証カスタム関数
│    │    ├── fetchClient.ts ...Fetch API クライアント
│    │    ├── supabase.ts ...supabaseクライアント
│    │    ├── queryClient.ts ...tanstackクライアント
│    │    ├── trpc.ts ...trpcクライアント
│    │    ├── util.ts ...ユーティリティ関数
│    │    ├── constants.ts ...定数設定
│    │    └── errors.ts ...カスタムエラー定義
│    ├── errors ...共通エラーハンドリング設定
│    ├── hooks ...共通フックスディレクトリ
│    │    ├── use-theme-provider ...テーマ切替状態管理
│    │    ├── use-session-observer ...ユーザー認証状態
│    │    ├── use-session-store ...ユーザー認証状態管理
│    │    ├── use-tanstack-query ...tanstack query共通フック
│    │    ├── use-suspense-query ...tanstack suspense query共通フック
│    │    ├── use-behavior-variant ...コンポーネント状態管理
│    │    ├── use-image-upload-tanstack 
│    │    ├── use-image-upload-trpc 
│    │    ├── use-local-file-manager ...選択ファイル状態管理
│    │    ├── use-toast ...toastUI状態管理
│    │    └── use-media-query ...メディアクエリ判別
│    ├── services ...共通サービス
│    ├── pages ...ページルーティング設定
│    ├── routes ...react-routerルーティング設定
│    ├── schemas ...共通スキーマ
│    ├── types ...共通型
│    └── App.tsx
├── prisma ...prismaスキーマ・マイグレーション
├── supabase/functions ...エッジファンクション
│    ├── _shared ...cors設定など
│    ├── cleanup-image-delete ...storageの定期削除
│    ├── delete-user-account ...アカウント削除
│    ├── sava-memo ...中間テーブルへの保存
│    └── trpc ...tRPC
├── tests ...ユニット/統合テスト
│    ├── components
│    ├── features
│    ├── hooks
│    └── pages
├── e2e ...e2eテスト/VRT
├── cicd ...github actions
│    ├── actions
│    └── workflows
├── index.html
├── tailwind.config.js
├── package.json
├── tsconfig.json
└── vite.config.js

レイヤー構造

App (App.tsx)
└── pages
    └─ components
         └─ features
             └─ hooks
                └─ services
                    └─ Supabase interface
                        ├── Edge Functions     
                        ├── Trigger Functions  
                        ├── Auth               
                        └── Database(postgres)           
graph TD
    App --> Pages
    Pages --> Components

    subgraph Features
        Components --> Hooks
        Hooks --> Services
    end

    Services --> Supabase[Supabase Client]

    subgraph Supabase Interface
        Supabase --> EdgeFunctions[Edge Functions] --> Database[Database - PostgreSQL]
        Supabase --> TriggerFunctions[Trigger Functions] --> Database
        Supabase --> Auth[Auth - RLS / Sessions] --> Database
        Supabase --> Database
    end
Loading

ER図

erDiagram
  User ||--o{ Memo : has
  User ||--|| Profile : has
  User ||--o{ Category : has
  User ||--o{ Tag : has
  User ||--o{ Image : has

  Memo ||--o{ MemoCategory : has
  Memo ||--o{ MemoTag : has
  Memo ||--o{ MemoImage : has

  Category ||--o{ MemoCategory : has
  Tag ||--o{ MemoTag : has
  Image ||--o{ MemoImage : has

  User {
    UUID id PK
  }

  Profile {
    UUID id PK
    UUID user_id FK
    String avatar
    String user_name
    DateTime created_at
    DateTime updated_at
  }

  Memo {
    UUID id PK
    UUID user_id FK
    String title
    String content
    String importance
    DateTime created_at
    DateTime updated_at
  }

  Category {
    int id PK
    UUID user_id FK
    String name
    DateTime created_at
    DateTime updated_at
  }

  MemoCategory {
    UUID memo_id FK
    int category_id FK
  }

  Tag {
    int id PK
    UUID user_id FK
    String name
    DateTime created_at
    DateTime updated_at
  }

  MemoTag {
    UUID memo_id FK
    int tag_id FK
  }

  Image {
    UUID id PK
    UUID user_id FK
    UUID storage_object_id
    String file_path
    String file_name
    Int file_size
    String mime_type
    DateTime created_at
    DateTime updated_at
  }

  MemoImage {
    UUID memo_id FK
    UUID image_id FK
    Int order
    String alt_text
    String description
    DateTime created_at
    DateTime updated_at
  }

  CleanupDeleteImage {
    UUID id PK
    String filePath
    String fileName
    UUID userId
    String errorMessage
    Boolean resolved
    DateTime createdAt
  }
Loading

メモ機能

  • メモにはタイトル、カテゴリー、コンテンツ、重要度、タグを入力できます
  • メモを追加するとメモの一覧が表示されます
  • 一覧表示からメモごとの編集と削除ができます

ファイルアップロード機能

  • ファイルアップロード・削除ができます
  • アップロード時の複数選択、サムネイル表示ができます
  • ファイルごとにコメントがつけられます
  • メモに追加・削除ができます

API切替

  • 複数のapiデータ通信の切替を行えます

  • supabaseClientクエリ + trigger functions

  • supabaseClientクエリ + tanstack Query + edge functions prisma/ drizzle

  • supabaseClientクエリ + tRPC + edge functions prisma

  • 切替はcomponents/variant-toggle, with-behavior-variant, hooks/use-behavior-variantを使用して行っています。

  • useBehaviorVariantにzustandストア作成、withBehaviorVariantで値を取得しコンポーネントを返す。

  • VariantToggleでuseBehaiorVariantのapiのidを切替、ページコンポーネント内でwithBehaviorVariantからコンポーネントを受け取るという流れです。

  • VariantToggleをグローバルヘッダーなどに、withBehaviorVariantを切り替えたいコンポーネントがあるページコンポーネントなどに配置します。

  • zustandのpersistでローカルストレージに保存し永続化しています。

  • fetures以下の各managerコンポーネントを切替るだけで、manager以下のコンポーネントは機能内で共通化しています。

フォームパーツコンポーネントの使い方

shadcn/uiのFormコンポーネント内で使用できる
パーツコンポーネントを読み込みlabel, placeholder, name, optionsを渡す

import { Form } from "@/components/ui/form";
import FormInput from "@/components/form/form-input";

...

<FormWrapper onSubmit={handleSubmit} form={form}>
    <FormInput label="タイトル" placeholder="タイトルを入力してください" name="title" />
    
    ...

ドロワー/ダイアログコンポーネントの使い方

  • メディアクエリによるドロワーとダイアログの切替が行えます
  • hooks/useMediaQueryでwindowサイズを取得して、切り替えたい値を設定しています
import ResponsiveDialog from "@/components/responsive-dialog"
import useMediaQuery  from "@/hooks/use-media-query"

...

<ResponsiveDialog open={open} onOpenChange={setOpen} isDesktop={isDesktop}  buttonTitle="メモ追加" dialogTitle="Memo" dialogDescription="メモを残そう" className="flex justify-center">
  <MemoForm onSubmit={handleFormSubmit} />
</ResponsiveDialog>

...

テーマトグルコンポーネントの使い方

  • システム / ライト / ダークのテーマ切替が行えます hooks/use-theme-providerでclassListからテーマclassを取得しコンテキストを作成、プロバイダーを上位に設置する
    グローバルヘッダーなどにcomponents/mode-toggleを設置して、useThemeフックを通じてテーマを渡して切り替えています
    テーマはローカルストレージに保存して永続化しています
import { ModeToggle } from "@/components/mode-toggle";

...

<ModeToggle />

...

トーストコンポーネントの使い方

ui/toasterからToastコンポーネントを取得し、上階層に配置します。
useToastからtoastを取得し、コンテンツを渡すとトーストが表示されます。

toast({title: "トーストが表示されました"})

Appはクライアント・プロバイダー、ルートはrouterに分離してあり、ToastはUIという事でlayoutに配置しています。
apiの状態は全てトースト表示をしています。

ファイルアップロードコンポーネントの使い方

components/file-uploader からFileUploader、components/file-thumbnailから FileThumbnailを取得します。
FileUploaderコンポーネントがfile選択、FileThumbnailが選択ファイルのサムネイル表示を担当しています。
UIを分離する事でファイル選択とサムネイル表示の位置をカスタマイズする事が可能になります。
内部でFormInputを使用しているので、FormWrapper内で使用すればrhfの機能と連携もできます。
ファイルに対する入力はuseFieldArrayで管理をしています。

<FileUploader files={files} onChange={onFileChange} onError={imageError} />
<FileThumbnail files={files} onDelete={onFileDelete} onRemove={removeFileMetadata} />

hooks/useLocalFileManagerで選択したファイルの管理を行います。 上位コンポーネントでuseLocalFileManagerからfilesを取得し、FileUploaderやFileThumbnailに渡します。

エラーハンドリング

  • apiエラーはerrors/error-handlerで管理しています。
  • TRPCはエラーフォーマッターで整形してから返しています。
  • TRPCのZodエラーに関してはUI側で取得しRHFのformState.errorsに渡して表示しています。

Sentry によるエラー監視

本番環境でのエラートラッキングとデバッグ効率化のため、Sentryを統合しています。

基本方針

フロント側のSentry:

  • すべてのユーザー操作に関連するエラーを監視
  • Edge Functionsを含む、フロント経由のすべてのAPI呼び出しエラーをカバー
  • 認証済みユーザーの情報を自動的に送信

バックエンド側のSentry:

  • フロントを経由しない処理(Cronジョブ、Webhook、バックグラウンド処理)のみに使用
  • 現在の対象: cleanup-image-delete(Cronジョブ)

フロント側の設定

環境変数

VITE_SENTRY_DSN=your-sentry-dsn

初期化(index.tsx)

import * as Sentry from "@sentry/react";
import { SENTRY_DSN, SENTRY_RELEASE, IS_PRODUCTION } from "@/lib/constants";

if (IS_PRODUCTION && SENTRY_DSN) {
  Sentry.init({
    dsn: SENTRY_DSN,
    release: SENTRY_RELEASE,
    environment: import.meta.env.MODE,
    sendDefaultPii: true,
    tracesSampleRate: 0.1,
  });
}

ユーザー情報の送信

エラー発生時のユーザー追跡のため、認証済みユーザーの情報をSentryに送信しています。

送信される情報:

  • ユーザーID(UUID)
  • メールアドレス(オプション)
  • ユーザー名(オプション)

実装場所: hooks/use-session-observer.tsxで、認証状態の変更時に自動的にsetSentryUser()を呼び出します。

import { setSentryUserFromSession } from '@/lib/constants';

useEffect(() => {
  supabase.auth.getSession().then(({ data: { session } }) => {
    // ... セッション設定
    
    // Sentryにユーザー情報を送信
    if (session?.user) {
      setSentryUser({
        id: session.user.id,
        email: session.user.email,
        username: session.user.user_metadata?.username,
      });
    } else {
      setSentryUser(null);
    }
  });

  const { data } = supabase.auth.onAuthStateChange((_, session) => {
    // ... セッション設定
    
    // Sentryにユーザー情報を送信
    if (session?.user) {
      setSentryUser({
        id: session.user.id,
        email: session.user.email,
        username: session.user.user_metadata?.username,
      });
    } else {
      setSentryUser(null);
    }
  });
}, []);

バックエンド側の設定(必要な場合のみ)

フロントを経由しない処理のみ、Edge Function内でinitSentry()を呼び出します。

対象:

  • Cronジョブ(例: cleanup-image-delete
  • Webhook処理
  • バックグラウンド処理

実装例(cleanup-image-delete):

import { initSentry, captureSentryError } from "../_shared/sentry.ts";

// Cronジョブなのでバックエンド側でSentry初期化
initSentry();

Deno.serve(async (_req) => {
  try {
    // クリーンアップ処理
    const results = await Promise.allSettled(
      records.map(async (record) => {
        const { error } = await supabase.storage.from(BUCKET_IMAGES).remove([record.file_path]);
        
        if (error) {
          // 個別ファイル削除エラーをSentryに送信
          captureSentryError(new Error(`ファイル削除エラー: ${record.file_path}`), {
            function: 'cleanup-image-delete',
            type: 'storage-delete-error',
            recordId: record.id,
            filePath: record.file_path,
          });
        }
      })
    );
    
    return new Response('ok', { status: 200 });
  } catch (error) {
    // 予期しないエラーをSentryに送信
    captureSentryError(error as Error, {
      function: 'cleanup-image-delete',
      type: 'cron-job-error',
    });
    
    return new Response(JSON.stringify({ error: error.message }), { status: 500 });
  }
});

重要: 通常のAPI(verify-sessionsave-memotrpcなど)では、バックエンド側のSentry設定は不要です。これらのエラーはフロント側で自動的にキャプチャされます。

リリースバージョン管理

Sentryには以下の形式でリリース情報が送信されます:

リリース情報はlib/constants.tsで自動生成されます:

export const SENTRY_RELEASE: string = import.meta.env.MODE === 'production'
  ? `${APP_NAME}@${APP_VERSION}-${GIT_SHA || 'unknown'}`
  : `${APP_NAME}@${APP_VERSION}-local`;

CI/CDでの設定

GitHub Actionsでビルド時にGit SHAを環境変数として注入:

- name: Build Application
  uses: ./.github/actions/build-app
  with:
    supabase-url: ${{ secrets.VITE_SUPABASE_URL }}
    supabase-anon-key: ${{ secrets.VITE_SUPABASE_ANON_KEY }}
    git-sha: ${{ github.sha }}
    sentry-dsn: ${{ secrets.VITE_SENTRY_DSN }}

エラーバウンダリーとの連携

既存のエラーバウンダリー(GlobalErrorBoundaryPageContentErrorBoundary)でキャッチされたエラーは、自動的にSentryに送信されます。

メリット

  • リリース単位でのエラー追跡: Git SHAによりエラーが発生したコミットを特定可能
  • ユーザー単位でのエラー追跡: 誰がエラーに遭遇したか特定可能、問い合わせ対応が容易
  • 環境別の監視: 本番/開発環境でエラーを分離して管理
  • パフォーマンス監視: tracesSampleRateによるパフォーマンス計測(10%サンプリング)
  • デバッグ効率化: スタックトレース、ブレッドクラム、ユーザー情報、コンテキスト情報の自動収集
  • フルスタック監視: フロント・バックエンド両方のエラーを一元管理

注意事項

  • Sentry DSNは本番環境のみ設定することを推奨(開発環境のノイズ削減)
  • sendDefaultPii: trueにより自動的にIPアドレスなどが収集されるため、プライバシーポリシーに記載が必要
  • ユーザー情報(メールアドレス含む)の送信についてもプライバシーポリシーに記載が必要
  • ログアウト時は自動的にSentryのユーザー情報がクリアされます

Fetch API クライアント

APIとの通信を行うためのカスタムクライアントです。

基本的な使い方

lib/fetchClient.ts に実装されています。

インスタンスの作成:

import { FetchClient } from "@/lib/fetchClient";

const httpClient = new FetchClient({
  baseUrl: "https://your-api-endpoint.com", // ベースURL
  timeout: 5000, // タイムアウト (ミリ秒)
  maxRetry: 3, // 最大リトライ回数
  // その他のオプション (retryDelay, baseBackoff, retryStatus, retryMethods)
});

リクエストの送信:

try {
  const data = await httpClient.get("/api/memos"); // GETリクエスト
  console.log(data);

  const newMemo = { title: "新しいメモ", content: "メモの内容" };
  const createdMemo = await httpClient.post("/api/memos", { body: JSON.stringify(newMemo) }); // POSTリクエスト
  console.log(createdMemo);

  // PUTリクエスト、DELETEリクエストも同様
  // const updatedMemo = await httpClient.put("/api/memos/1", { body: JSON.stringify({ content: "更新された内容" }) });
  // await httpClient.delete("/api/memos/1");
} catch (error) {
  console.error("APIエラー:", error);
}

特徴

  • ベースURL設定: インスタンス作成時にAPIのベースURLを設定できます。
  • タイムアウト: リクエストのタイムアウト時間を設定できます。
  • リトライ: 500系のサーバーエラーとネットワークエラー発生時に、設定された回数まで自動的にリトライを行います。リトライ対象のステータスコードとHTTPメソッドはオプションで設定可能です。
  • デフォルトヘッダー: Content-Type: application/json がデフォルトで設定されています。
  • エラーハンドリング: タイムアウト、ネットワークエラー、HTTPエラー、JSONパースエラーなどのカスタムエラークラス (lib/errors.ts に定義) を使用して、より具体的なエラー情報を取得できます。

tanstack queryカスタムフック

基本的な使い方

hooks/use-tanstack-query.ts に実装されています。

const { isLoading, data } = useApiQuery({
      queryKey: ['key'],
      queryFn:  () => getApiData(),
      enabled: false,
   },{
      onSuccess: () => console.log("success!"),
      onError: () => console.log("error!"),
      onSettled: () => console.log("finish!"),
   })

const { isPending, data } = useApiMutation({
      mutationFn:  () => getApiData(),
      enabled: false,
      onSuccess: () => console.log("success!"),
      onError: () => console.log("error!"),
      onSettled: () => console.log("finish!"),
   })

CI/CD ワークフロー

このプロジェクトでは、GitHub Actionsを使用したCI/CDを実行します。ViteアプリケーションとSupabaseの連携は、環境変数によって管理されています。 envの環境変数を環境ごとに設定し、デプロイ先へ設定する。DB接続先などの環境変数は、デプロイ先のサービス(Netlify, Vercel など)で設定されており、コードには含まれません。

ローカル開発環境

  • npm run devでの実行
  • supabase cliでsupabase studio(Local Supabase Stack)に接続

デプロイ環境

dev環境

  • feature(local)->devブランチへのpush/PRをトリガーにCI/CD実行
  • supabase dev用プロジェクトへ接続

prod環境

  • dev->mainブランチへのpush/PRをトリガーにCI/CD実行
  • supabase prod用プロジェクトへ接続

実行する事

  • nodeインストール

  • npm ci(install)

  • npm lint

  • npm build

  • unit/integrationテスト vitest(components/features/hooks/lib/pages)

  • deno unitテスト/lint

  • prisma generate/migrate

  • playwright e2eテスト(msw/VRT)

  • edge function deploy

  • buildアーティファクト deploy

ワークフローの流れ

graph LR
    A([Feature Branch]) --> B[PR to dev]
    B --> C[CI]
    B --> D[E2E]
    C --> E{Tests Pass?}
    D --> E
    E -->|Yes| F[Merge to dev]
    E -->|No| G[Fix Issues]
    F --> H[Deploy Dev]

    F --> I[PR to main]
    I --> J[CI]
    I --> K[E2E]
    J --> L{Tests Pass?}
    K --> L
    L -->|Yes| M[Merge to main]
    L -->|No| N[Fix Issues]
    M --> O[Deploy Prod]
Loading
トリガー ワークフロー 環境 実行内容 目的
PR → dev ci.yml + e2e.yml development + test-dev 品質チェック + E2Eテスト dev環境での動作保証
PR → main ci.yml + e2e.yml production + test-prod 品質チェック + E2Eテスト 本番環境での動作保証
Push → dev ci.yml + e2e.yml + deploy-dev.yml development テスト + デプロイ dev環境更新
Push → main ci.yml + e2e.yml + deploy-prod.yml production テスト + デプロイ 本番環境リリース
  1. 開発フロー feature/* → dev (PR) → main (PR) → デプロイ
  2. 具体的な実行タイミング PR作成時(dev/mainへの全PR):
  • ✅ CI Pipeline (test, lint, build)
  • ✅ E2E Tests
  • ✅ 品質チェック完了後にマージ可能

マージ/Push時:

  • ✅ devブランチへのpush → Dev環境自動デプロイ
  • ✅ mainブランチへのpush → Prod環境自動デプロイ
  1. 実際の作業手順例
bash# 1. 機能開発
git checkout -b feature/memo-enhancement
# 開発作業...
git push origin feature/memo-enhancement

# 2. dev環境へのPR
# → CI + E2E自動実行
# → テスト通過後にマージ
# → dev環境に自動デプロイ

# 3. 本番リリース準備
# dev → main PR作成
# → CI + E2E自動実行  
# → テスト通過後にマージ
# → prod環境に自動デプロイ

GitHub Actions での環境ごとの環境変数設定

環境ごとに環境変数を切り替える方法はいくつかあります。

  • .env.dev, .env.prod などファイルを分離する → .envは基本的にリポジトリにpushしない

  • VITE_LOCAL_, VITE_DEV_, VITE_PROD_ などキー名で分岐 → キーが大量になり管理が煩雑

  • GitHubのSecretsに環境ごと移す → diffが分からない

  • GitHubのEnvironments機能でSecrets / Variablesを分岐 ✅

この中で最も管理がシンプルなのがEnvironments Secretsを使う方法です。

設定手順

  1. GitHubリポジトリのSettings → Environments → New environmentで環境名を作成

  2. Add environment secretsでシークレットを登録、variablesで定数を登録

  3. 環境ごとに同じキー名で設定(例: VITE_SUPABASE_URL)

💡 補足

  • Secrets: APIキーや認証情報など秘匿すべき値

  • Variables: 環境名やフラグなど公開してもよい定数

使い方

各ジョブにenvironment: を指定するだけで、その環境のSecrets / Variablesが自動で読み込まれます。

jobs:
  build:
    runs-on: ubuntu-latest
    environment: dev
    steps:
      - uses: actions/checkout@v4
      - run: echo "SUPABASE_URL=${{ secrets.VITE_SUPABASE_URL }}"

ブランチごとに自動で切り替える場合

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.ref == 'refs/heads/main' && 'prod' || 'dev' }}

workflow_dispatchで選択する場合

on:
  workflow_dispatch:
    inputs:
      target:
        type: choice
        options: [dev, prod]

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: ${{ github.event.inputs.target }}

共通部分をcomposite actionsへ

このワークフローではいくつか共通するステップがありますので、composite actionsとして共通化しています。 これにより、記述の重複を減らし、変更があった場合も一箇所を修正すれば全体に反映されます。

現状共通化しているのはbuildステップ(build-app)と、node,denoインストールとキャッシュステップ、npm ciのステップ(setup-environment)です。

  • setup-environment

    • Node.js / Deno のインストール
    • キャッシュ設定
    • npm ci 実行
  • build-app

    • アプリケーションのビルド処理

利用例:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: ./.github/actions/setup-environment
      - uses: ./.github/actions/build-app

AWS S3デプロイでOIDC認証する場合

フロントエンドアプリケーションのデプロイ先がAWS S3の場合、AWSへ認証する必要が有ります。 その際アクセスキーをシークレットに保存ではなくOIDCで認証する場合、IAM roleをシークレットに保存し、role-to-assume似設定する必要があります。 cloudfrontを使用している場合、CDNキャッシュをクリアする必要が有ります。

デプロイ設定手順

  1. Terraform出力から以下の値を取得:
    • AWS_ROLE_ARN: terraform output -json github_actions_role_arns
  2. GitHub Secrets設定:
    • AWS_ROLE_ARN: 上記で取得した値
    • S3_BUCKET_NAME: プロジェクト名-環境名-frontend
    • CLOUDFRONT_DISTRIBUTION_ID: 別途CloudFront作成後に設定
- name: Configure AWS credentials
  uses: aws-actions/configure-aws-credentials@v4
  with:
    role-to-assume: ${{ secrets.AWS_ROLE_ARN }}  # ← ここで使用
    role-session-name: deploy-to-dev
    aws-region: ap-northeast-1

error-boundaryの設置場所

このアプリケーションでは、階層的なエラーバウンダリーを採用し、エラーの影響範囲を最小限に抑えながら、ユーザーに適切なフィードバックを提供しています。

構造階層

GlobalErrorBoundary (index.tsx)
  └─ App
      └─ RouterProvider
          ├─ LayoutWrapper
          │   ├─ Layout (Header + Footer)
          │   └─ PageContentErrorBoundary
          │       └─ Outlet (各ページコンポーネント)
          │
          └─ AuthLayoutWrapper
              ├─ AuthLayout (認証済みユーザー用レイアウト)
              └─ PageContentErrorBoundary
                  └─ Outlet (認証が必要なページ)

エラーバウンダリーの種類

GlobalErrorBoundary(グローバルレベル)

  • アプリケーション全体の致命的なエラーをキャッチ
  • 白画面(White Screen of Death)の防止
  • アプリケーションが完全にクラッシュした場合のフォールバックUI表示

PageContentErrorBoundary(ページレベル)

  • 各ページコンポーネント内のエラーをキャッチ
  • Header/Footerは表示を維持(ナビゲーション可能な状態を保つ)
  • ページ単位でのエラーハンドリング

ComponentErrorBoundary(コンポーネントレベル)

  • 特定のUIコンポーネント内のエラーをキャッチ
  • エラーが発生しても、ページ全体や他のコンポーネントは正常動作
  • 非致命的なエラーの局所化

エラーログ

すべてのエラーバウンダリーは、キャッチしたエラーをコンソールに出力しますが、 本番環境では、外部エラー監視サービス(Sentry等)への送信を実装することを推奨します。

error boundaryとsuspenceの統合

error boundaryをindex.tsxと各プレゼンテーション層に設置しており、初期読み込み時とページ読み込み時でのsuspenceと統合する事で同じパターンの設計として統一できます。

統一する場合は、components/async-boundary.tsxを作成し、error boundaryをラップする形でコンポーネントを作成します。 設置場所はerror boundaryと同様の設計になります。

  • 一体管理: ErrorBoundaryとSuspense を1つのコンポーネントで管理
  • シンプルな使用: ラッパー1つでエラーとローディング両方に対応
  • 一貫性: エラーバウンダリーと同じ設計思想
  • DRY原則: ネストを最小限に

suspenceを使用する場合は、lazyでコンポーネントの非同期による遅延読み込みを使用してローディングなどUI表示を行なう事になります。

pages内コンポーネントのlazy pages内のプレゼンテーション層をlazyで非同期読み込みとする場合、pages/で個別に設定するのではなく、先のerror-boundaryとsuspenceと同様にroutes/router.tsxで行います。これによって設定を一元化でき保守管理しやすくなります。

useSuspenseQueryの使用

tanstack queryにはuseQueryとuseSuspenseQueryがあり、アプリでsuspenseを使用する場合、useSuspenseQueryでfallbackさせる方が、前述のerror-boundaryと併せて責務分離がしやすくなります。

責務の分離

  • データ取得・キャッシュ: useSuspenseQuery
  • エラー処理: ErrorBoundary
  • ローディング表示: Suspense

useQueryとの比較

useQuery useSuspenseQuery
データ型 T | undefined T(必ず存在)
ローディング isLoadingで判定 Suspenseが処理
エラー isErrorで判定 ErrorBoundaryが処理
使用場所 コンポーネント内で完結 AsyncBoundaryが必要

プロジェクトでのuseQueryからのリファクタリング移行

useQueryからuseSuspenseQueryへリファクタリングする場合の移行手順としては、まず前述のerror-boundaryとsuspenseのコンポーネント作成を行っておく。

  1. error-boundary.tsxとasync-boundary.tsxの作成
  2. error-boundaryにerrorが渡された後の処理を追加
  3. 共通フックuse-suspense-query.tsを作成
  4. 機能フックuse-memo-suspense-query-tanstack.tsを作成
  5. 従来の出力名と同じ出力名でリファクタリングを行う
  6. UI層にasync-boundaryで作成したコンポーネント用のboundaryで対象コンポーネントを囲う

元の設計でエラー表示などの分岐処理を分離していると、error-boundaryのcomponentDidCatchから分岐メソッドを呼び出しエラーを渡すだけとなります。

エラー処理フロー

useSuspenseQueryでエラー発生
  ↓
ErrorBoundary.componentDidCatch()
  ↓
errorHandler(error) ← 既存の共通エラーハンドラー
  ↓
toast({ title: "..." }) ← エラー通知表示

認証チェックにtanstackを使用する場合

認証チェックは基本的にsupabaseクライアントを使用しておこないますが、tanstack queryを使用する事で責務分離が分かり易くなります。

tanstack queryを使用した場合の担当責務

機能 担当
セッション取得/キャッシュ TanStack Query
グローバル状態反映(UI依存部分) Zustand
認証イベント購読 Supabase (onAuthStateChange)
Query/Zustand間の同期 useEffect

認証チェック実装の比較

観点 useSessionObserver(従来) useSessionMonitor(TanStack Query版)
データ取得 Supabaseから直接取得、useStateで保持 useQuery経由で取得、キャッシュに保持
状態管理 Zustandのみ TanStack Query(主) + Zustand(同期用)
再マウント時 再フェッチが発生する可能性あり キャッシュから即座に復元(staleTime: Infinity
ローディング管理 手動でuseStateを管理 isLoadingが自動提供
エラーハンドリング 手動でtry/catchが必要 erroronErrorで自動処理可能
責務分離 1つのhookで取得・状態管理・購読を担当 Query(取得・キャッシュ)/ Zustand(UI状態)で分離
保守性 シンプルで小規模向き 中〜大規模で拡張性が高い
React Query親和性 なし(独立管理) 高い(他のQueryとの連携が容易)

選択基準

  • 従来版(useSessionObserver): 小規模プロジェクト、シンプルさ重視
  • TanStack Query版(useSessionMonitor): 中〜大規模、既にTanStack Queryを使用、責務分離重視

セッション保存にRedisを使用する場合

🚀 メリット

  • Supabaseへの不要なAuthアクセスを大幅削減
  • Redis経由でセッションフェッチが高速化
  • TanStack QueryとTTL同期で安定したキャッシュ挙動
  • SSRや他クライアントからの認証確認にも再利用可能(verify-sessionを共通APIとして利用)
  • Rate Limitingによるセキュリティ向上
  • セッション無効化(Blacklist)機能による強制ログアウト対応

Upstash Redisを選択する理由

このプロジェクト(SPA + Supabase Edge Functions)では Upstash Redis が最適

  • ✅ Edge Functionsから直接アクセス可能(HTTP REST API)
  • ✅ サーバーレス環境にマッチ
  • ✅ 低トラフィック時のコストが最小
  • ✅ インフラ管理不要
  • ✅ グローバルエッジで低レイテンシー

将来の拡張性 将来的にトラフィックが増加した場合は、バックエンドをVPC環境に移行し、ElastiCacheへの切り替えを検討すれば良い。標準Redisコマンドを使用していれば移行は容易。

環境変数の設定 upstash Redisを使用する場合、環境変数に追加の設定が必要になります。

UPSTASH_REDIS_REST_URL=example_url
UPSTASH_REDIS_REST_TOKEN=example_token

セッション管理の取得方法

アプリでは、環境に応じてセッション取得元を切り替えられるように設計されています。

環境 取得方法 利用先 キャッシュ戦略
通常運用(開発/ローカル) supabase.auth.getSession() Supabaseクライアントから直接取得 TanStack Query(5分)
パフォーマンス最適化(本番) supabase.functions.invoke('verify-session') Edge Function + Redisキャッシュ経由 Redis(1時間)+ TanStack Query(手動もしくは5分)

Edge Function経由の場合、Redisにセッション情報がキャッシュされているため、Supabase Auth APIへのリクエスト回数を削減し、レスポンス速度を大幅に改善します(約10-30ms)。

フォールバック設計

Edge Function(Redis経由)の取得が失敗した場合でも、クライアントは自動的に supabase.auth.getSession() へフォールバックします。

フォールバックが発動するケース:

  • Redis(Upstash)の障害・メンテナンス
  • Edge Functionのデプロイ中・障害
  • ネットワークエラー
  • Rate Limit超過

これにより、Redis や Edge Function の障害時でもセッション取得が継続可能で、サービスの可用性が保たれます。

キャッシュ戦略の詳細

┌─────────────────────────────────────────┐
│ 1. TanStack Query (5分キャッシュ)        │
│    - クライアント側メモリキャッシュ        │
│    - 再マウント時も有効                   │
└────────────┬────────────────────────────┘
             ↓ (キャッシュミス)
┌────────────┴────────────────────────────┐
│ 2. Edge Function                        │
│    ↓                                    │
│ 3. Redis (1時間キャッシュ)               │
│    - サーバー側グローバルキャッシュ        │
│    - 全ユーザー・デバイス間で共有          │
└────────────┬────────────────────────────┘
             ↓ (キャッシュミス)
┌────────────┴────────────────────────────┐
│ 4. Supabase Auth API                    │
│    - 最新のセッション情報を取得            │
└─────────────────────────────────────────┘

セッション削除時の挙動

ユーザーがログアウトした際の処理フロー:

  1. クライアント側: supabase.auth.signOut() でSupabase側のセッションを削除
  2. Edge Function: session-delete を呼び出し、Redisキャッシュも削除
  3. TanStack Query: クライアント側のキャッシュをクリア
// onAuthStateChange内での処理
if (event === 'SIGNED_OUT') {
  try {
    await supabase.functions.invoke('session-delete', {
      method: 'DELETE',
    });
  } catch (error) {
    console.error('Failed to delete session cache:', error);
    // TTLで自動削除されるため、失敗しても問題なし
  }
}

重要: session-delete が失敗しても問題ありません。RedisのTTL(1時間)により自動的にキャッシュは削除されます。

Rate Limiting

Edge Functionには、不正アクセスやDDoS攻撃を防ぐためのRate Limitingが実装されています。

状態 制限 説明
認証済み 20リクエスト/10秒 JWT形式の有効なトークンを持つユーザー
未認証 10リクエスト/10秒 トークンなし、または不正な形式のトークン

Rate Limitに達した場合、以下のレスポンスヘッダーが返されます:

  • X-RateLimit-Limit: 制限値
  • X-RateLimit-Remaining: 残りリクエスト数
  • X-RateLimit-Reset: 制限リセット時刻(UNIX timestamp)

Rate Limit超過時は自動的にフォールバック(supabase.auth.getSession())に切り替わります。

注意点とまとめ

shadcn/ui

  • shadcn/uiのFormコンポーネントはzodとreact-hook-formと連携しているのでインストールする必要がある
  • FormとRHFを連係するとUIを構成するコンポーネントが多く、コードが長くなるのでパーツごとにコンポーネントにしておく
  • shadcn/uiのドロワーはVaulを使用しているので、気になるならドロワーだけ変更を検討する。

supabase/prisma/postgres

  • supabaseとprismaは型を出力できるが、DBの型はsupabase、入力はzod-prisma-typesなどライブラリを使用するなど区別すると分かりやすい
  • prismaのバグでuuid_generate_v4と@updateAtのスキーマが使えないので、gen_random_uuid()とdefault(now())とplpgトリガーで対応する必要がある
  • 上記問題はprismaのgithubイシューでは修正されクローズされているが、未だに同イシューに報告が挙がっている
  • prismaでカラムを配列型にするとnot nullにならないので後でSQLで行う必要がある
  • supabaseからのコールバックはPKCEで自動処理されるのでパスクエリ判別はできないのでidentitiesなどから判別する必要がある
  • webhookからのコールバックでAppが再マウントするのでcallback用ページで受けてから遷移する必要がある
  • supabase storageは初期値でCDNでキャッシュされるため適宜ハッシュを付けておく
  • supabase edgeでのprisma clientはdeno用のedgeを使用する必要がある
  • supabase edgeでprismaでpostgresにクエリ送信するにはdeno用adapterがないのでaccelerate経由でクエリを送信する必要がある
  • accelerateを使用しない場合、pgpl関数をRPCで呼ぶ、設計変える、drizzleに変えるなどがある
  • supabase rpcで呼び出せるのはfunctionでありprocedureではない
  • plpgのfunction内でトランザクションコマンドは使用できない。function内でprocedureを呼んでも使用不可。
  • plpg内のfunction内では例外発生で自動的にロールバックする。raise exceptionでEXCEPTIONブロックでもロールバックできる。
  • plpg内のprocedure内でトランザクションコマンドは使用できるが、制限が多く基本的には使用不可。自動ロールバックで行う必要がある。

vite/vitest

  • react18ではtesting-library/react-hooksではなくtesting-library/reactを使用する
  • viteでrequireが使用できないということはvitestでも使用できない
  • shadcn/uiのフォームなどのDOM構造はボタン制御が多く、内部非同期も多いのでvitestでのawait, waitFor, actの警告がでやすい
  • vitestでは複数の関数をモックする事はできないのでファイルを分割するか処理を分けるかなど対処が必要
  • 例えばある関数が成功したら別の関数が起動して処理を行うなどのモックのexpectは一つになる

tanstack Query/tRPC

  • tRPCはv9, v10, v11で書き方から使えるメソッドやプロパティも異なるが互換性はある
  • tRPCでのtanstack Queryも同様に統合前後で異なる、共通化処理の型が複雑
  • tRPCのエラーはTRPCErrorよりTRPCClientErrorにフォーマットで出力した方が扱いやすい
  • zodErrorをtrpcで他言語化する場合サーバーとクライアントの両方にmapperを置く必要がある
  • tanstack queryのuseQueryはv5でoptionsのコールバックが幾つか削除されており、自分で実装する必要がある
  • trpcクライアントでedge側のAppRouter型を使用しているのでviteでbuild時にdenoのエラーがでる。対応策としてbuild前にtrpcの型だけ共通ディレクトリに生成してtrpcクライアントのAppRouterとして使用すればbuild自体はできる。多少の型エラーがでる場合は修正するかts-ignoreする

比較

機能 function内 procedure内 備考
トランザクションコマンド ❌ 使用不可 ⚠️ 制限あり 基本的には自動ロールバック推奨
自動ロールバック ✅ 例外発生時 ✅ 例外発生時 raise exceptionで制御可能
supabase RPC呼び出し ✅ 可能 ❌ 不可 functionのみRPCで呼び出し可能

方式 コード量 複雑性 パフォーマンス
supabaseClient + trigger functions 普通
supabaseClient + tanstack Query + edge functions + drizzle
supabaseClient + tRPC + edge functions + prisma
  • edge functionsではコールドスタートがあるためアクセスによってパフォーマンスが悪化する場合があります
  • tanstack Query、tRPC、ORMは多少のオーバーヘッドはありますが、そこまでパフォーマンスは変化しません
  • 単純な入出力は上記の通りですが、入力後に一覧更新などのパフォーマンスはtanstack Query使用の方が良い

UX

  • この設計ではfeaturesで機能ごとに分離していますが、メモ追加時にカテゴリ・タグ・ファイルの新規追加ができるようになっています。
  • 本来この設計で新規追加する場合、カテゴリ・タグ・ファイルを個別に追加し、メモ追加時にそれらを添付するシステムになるが、メモ追加時に全て追加出来た方がユーザー体験としては良いと考えてmemo内に処理を記載している
  • ユーザーはメモ作成時に必要な情報を一度に完結できるため、操作の手間が減り、直感的に利用できると考えます
  • 一方で、コンポーネント肥大化やProp Drillingになりやすく、複数の機能が密接に連携することで関心事の分離が曖昧になり複雑性も増すので、設計どおり個別実装した方が保守管理はしやすいです
  • アカウントとプロフィールにも同様のことがいえます

playwright/msw

  • E2EテストにはPlaywrightを使用しています。
  • Supabase SDK(特にauth周り)は、エラーコードを HTTP レスポンスとして返すのではなく、SDK内部でAuthErrorクラスとしてラップするため、Playwright のroute()による HTTPレベルのモックでは意図通りの挙動が再現できません。
  • 例えば、エラー表示ロジックを検証しようとroute()で返り値をモックしても、SDKが期待するエラー形式(AuthError)でないためundefined扱いになり、クライアント側のエラーハンドリングが正しく動作しません。
  • このため、MSWを使ってSupabase SDK経由で実際にリクエストを発行し、それに対してエラーを返す必要があります。
  • ただし、通常のMSW 設定(ブラウザ側で起動)では、アプリ本体にテスト用コードが混在してしまう問題があります。
  • そこで、テストコードからのみMSWを制御可能にするplaywright-mswを採用し、テスト側からAPIをインターセプトしています。
  • 認証状態のテストでは、auth.setupを作成し、その中でstorageStateに認証情報を保存しています。playwright.configのprojectsに作成したstorageStateを設定し、特定ファイルで使用するように設定をします。
  • 初期設定ハンドラーではデータが空の状態、worker.useでデータがある状態のハンドラーとして分離しています。これによってテストコード、セットアップ、ハンドラー、フィクスチャーと責務分離ができます。
  • ファイルアップロードや画像更新の処理では先にテスト用のファイルをローカルとstorageに用意し、固定URLを使用してハンドラーを作成しています。テストごとの画像状態に変化をなくすことでVRTが安定しテストの独立性・再現性が保たれます。
  • メール送信テストにはMailTrapとMailSlurpを使用しており、apiから認証リンクを取得しテストを行っています。
  • アドレス変更は専用アカウントは作成せず、変更後に元に戻すテストをしており、これによって認証用テストアカウントは一つで済みますので運用がシンプルになります。

shadcn/ui

  • UIコンポーネントライブラリをプロジェクトにコピーして使用できるので便利ですが、DOMは調べる必要があります。
  • テスト時にはdata-testIdなどをつけるか実際のDOMを見るかする必要があります。
  • 基本的にはブラウザのdev toolで事足りる。
  • storybookの様なUIコンポーネントカタログを作成するのも良いが、DOM見るだけに導入する事は避ける方が良い。導入したら保守・運用が発生するコストと見合うかどうかを検討する必要があります。

## 今後の課題

  • オブザーバビリティ可観測性(sentry APM検討)
  • openAPI(zod-openAPI検討)

About

react-spa-architecture vite typescript shadcn/ui tailwind fetch-api-http-client supabase-auth supabase-database supabase-edge-functions prisma drizzle postgres tanstack-query tRPC zod zustand node deno hono vitest playwright redis severless

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

No packages published

Languages