- 概要
- 目的
- 開発環境
- ディレクトリ構造
- レイヤー構造
- ER図
- メモ機能
- ファイルアップロード機能
- API切替
- フォームパーツコンポーネントの使い方
- ドロワー/ダイアログコンポーネントの使い方
- テーマトグルコンポーネントの使い方
- トーストコンポーネントの使い方
- ファイルアップロードコンポーネントの使い方
- エラーハンドリング
- Sentry によるエラー監視
- Fetch API クライアント
- tanstack queryカスタムフック
- CI/CD ワークフロー
- error-boundaryの設置場所
- error boundaryとsuspenceの統合
- useSuspenseQueryの使用
- 認証チェックにtanstackを使用する場合
- セッション保存にRedisを使用する場合
- 注意点とまとめ
- 今後の課題
拡張性と保守性を重視して設計された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
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
}
- メモにはタイトル、カテゴリー、コンテンツ、重要度、タグを入力できます
- メモを追加するとメモの一覧が表示されます
- 一覧表示からメモごとの編集と削除ができます
- ファイルアップロード・削除ができます
- アップロード時の複数選択、サムネイル表示ができます
- ファイルごとにコメントがつけられます
- メモに追加・削除ができます
-
複数の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:
- すべてのユーザー操作に関連するエラーを監視
- Edge Functionsを含む、フロント経由のすべてのAPI呼び出しエラーをカバー
- 認証済みユーザーの情報を自動的に送信
バックエンド側のSentry:
- フロントを経由しない処理(Cronジョブ、Webhook、バックグラウンド処理)のみに使用
- 現在の対象:
cleanup-image-delete(Cronジョブ)
VITE_SENTRY_DSN=your-sentry-dsnimport * 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-session、save-memo、trpcなど)では、バックエンド側のSentry設定は不要です。これらのエラーはフロント側で自動的にキャプチャされます。
Sentryには以下の形式でリリース情報が送信されます:
- 本番環境:
アプリ名@バージョン-GitSHA(例:[email protected]) - 開発環境:
アプリ名@バージョン-local(例:[email protected])
リリース情報は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`;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 }}既存のエラーバウンダリー(GlobalErrorBoundary、PageContentErrorBoundary)でキャッチされたエラーは、自動的にSentryに送信されます。
- ✅ リリース単位でのエラー追跡: Git SHAによりエラーが発生したコミットを特定可能
- ✅ ユーザー単位でのエラー追跡: 誰がエラーに遭遇したか特定可能、問い合わせ対応が容易
- ✅ 環境別の監視: 本番/開発環境でエラーを分離して管理
- ✅ パフォーマンス監視:
tracesSampleRateによるパフォーマンス計測(10%サンプリング) - ✅ デバッグ効率化: スタックトレース、ブレッドクラム、ユーザー情報、コンテキスト情報の自動収集
- ✅ フルスタック監視: フロント・バックエンド両方のエラーを一元管理
- Sentry DSNは本番環境のみ設定することを推奨(開発環境のノイズ削減)
sendDefaultPii: trueにより自動的にIPアドレスなどが収集されるため、プライバシーポリシーに記載が必要- ユーザー情報(メールアドレス含む)の送信についてもプライバシーポリシーに記載が必要
- ログアウト時は自動的にSentryのユーザー情報がクリアされます
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 に定義) を使用して、より具体的なエラー情報を取得できます。
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!"),
})このプロジェクトでは、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]
| トリガー | ワークフロー | 環境 | 実行内容 | 目的 |
|---|---|---|---|---|
| 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 |
テスト + デプロイ | 本番環境リリース |
- 開発フロー feature/* → dev (PR) → main (PR) → デプロイ
- 具体的な実行タイミング PR作成時(dev/mainへの全PR):
- ✅ CI Pipeline (test, lint, build)
- ✅ E2E Tests
- ✅ 品質チェック完了後にマージ可能
マージ/Push時:
- ✅ devブランチへのpush → Dev環境自動デプロイ
- ✅ mainブランチへのpush → Prod環境自動デプロイ
- 実際の作業手順例
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環境に自動デプロイ
環境ごとに環境変数を切り替える方法はいくつかあります。
-
.env.dev, .env.prod などファイルを分離する → .envは基本的にリポジトリにpushしない
-
VITE_LOCAL_, VITE_DEV_, VITE_PROD_ などキー名で分岐 → キーが大量になり管理が煩雑
-
GitHubのSecretsに環境ごと移す → diffが分からない
-
GitHubのEnvironments機能でSecrets / Variablesを分岐 ✅
この中で最も管理がシンプルなのがEnvironments Secretsを使う方法です。
設定手順
-
GitHubリポジトリのSettings → Environments → New environmentで環境名を作成
-
Add environment secretsでシークレットを登録、variablesで定数を登録
-
環境ごとに同じキー名で設定(例: 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として共通化しています。 これにより、記述の重複を減らし、変更があった場合も一箇所を修正すれば全体に反映されます。
現状共通化しているのは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の場合、AWSへ認証する必要が有ります。 その際アクセスキーをシークレットに保存ではなくOIDCで認証する場合、IAM roleをシークレットに保存し、role-to-assume似設定する必要があります。 cloudfrontを使用している場合、CDNキャッシュをクリアする必要が有ります。
デプロイ設定手順
- Terraform出力から以下の値を取得:
- AWS_ROLE_ARN:
terraform output -json github_actions_role_arns
- AWS_ROLE_ARN:
- 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このアプリケーションでは、階層的なエラーバウンダリーを採用し、エラーの影響範囲を最小限に抑えながら、ユーザーに適切なフィードバックを提供しています。
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を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で行います。これによって設定を一元化でき保守管理しやすくなります。
tanstack queryにはuseQueryとuseSuspenseQueryがあり、アプリでsuspenseを使用する場合、useSuspenseQueryでfallbackさせる方が、前述のerror-boundaryと併せて責務分離がしやすくなります。
責務の分離
- データ取得・キャッシュ: useSuspenseQuery
- エラー処理: ErrorBoundary
- ローディング表示: Suspense
useQueryとの比較
| useQuery | useSuspenseQuery | |
|---|---|---|
| データ型 | T | undefined | T(必ず存在) |
| ローディング | isLoadingで判定 | Suspenseが処理 |
| エラー | isErrorで判定 | ErrorBoundaryが処理 |
| 使用場所 | コンポーネント内で完結 | AsyncBoundaryが必要 |
useQueryからuseSuspenseQueryへリファクタリングする場合の移行手順としては、まず前述のerror-boundaryとsuspenseのコンポーネント作成を行っておく。
- error-boundary.tsxとasync-boundary.tsxの作成
- error-boundaryにerrorが渡された後の処理を追加
- 共通フックuse-suspense-query.tsを作成
- 機能フックuse-memo-suspense-query-tanstack.tsを作成
- 従来の出力名と同じ出力名でリファクタリングを行う
- UI層にasync-boundaryで作成したコンポーネント用のboundaryで対象コンポーネントを囲う
元の設計でエラー表示などの分岐処理を分離していると、error-boundaryのcomponentDidCatchから分岐メソッドを呼び出しエラーを渡すだけとなります。
useSuspenseQueryでエラー発生
↓
ErrorBoundary.componentDidCatch()
↓
errorHandler(error) ← 既存の共通エラーハンドラー
↓
toast({ title: "..." }) ← エラー通知表示
認証チェックは基本的にsupabaseクライアントを使用しておこないますが、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が必要 | errorやonErrorで自動処理可能 |
| 責務分離 | 1つのhookで取得・状態管理・購読を担当 | Query(取得・キャッシュ)/ Zustand(UI状態)で分離 |
| 保守性 | シンプルで小規模向き | 中〜大規模で拡張性が高い |
| React Query親和性 | なし(独立管理) | 高い(他のQueryとの連携が容易) |
- 従来版(useSessionObserver): 小規模プロジェクト、シンプルさ重視
- TanStack Query版(useSessionMonitor): 中〜大規模、既にTanStack Queryを使用、責務分離重視
🚀 メリット
- Supabaseへの不要なAuthアクセスを大幅削減
- Redis経由でセッションフェッチが高速化
- TanStack QueryとTTL同期で安定したキャッシュ挙動
- SSRや他クライアントからの認証確認にも再利用可能(verify-sessionを共通APIとして利用)
- Rate Limitingによるセキュリティ向上
- セッション無効化(Blacklist)機能による強制ログアウト対応
このプロジェクト(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 │
│ - 最新のセッション情報を取得 │
└─────────────────────────────────────────┘
ユーザーがログアウトした際の処理フロー:
- クライアント側:
supabase.auth.signOut()でSupabase側のセッションを削除 - Edge Function:
session-deleteを呼び出し、Redisキャッシュも削除 - 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時間)により自動的にキャッシュは削除されます。
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のFormコンポーネントはzodとreact-hook-formと連携しているのでインストールする必要がある
- FormとRHFを連係するとUIを構成するコンポーネントが多く、コードが長くなるのでパーツごとにコンポーネントにしておく
- shadcn/uiのドロワーはVaulを使用しているので、気になるならドロワーだけ変更を検討する。
- 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内でトランザクションコマンドは使用できるが、制限が多く基本的には使用不可。自動ロールバックで行う必要がある。
- react18ではtesting-library/react-hooksではなくtesting-library/reactを使用する
- viteでrequireが使用できないということはvitestでも使用できない
- shadcn/uiのフォームなどのDOM構造はボタン制御が多く、内部非同期も多いのでvitestでのawait, waitFor, actの警告がでやすい
- vitestでは複数の関数をモックする事はできないのでファイルを分割するか処理を分けるかなど対処が必要
- 例えばある関数が成功したら別の関数が起動して処理を行うなどのモックのexpectは一つになる
- 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使用の方が良い
- この設計ではfeaturesで機能ごとに分離していますが、メモ追加時にカテゴリ・タグ・ファイルの新規追加ができるようになっています。
- 本来この設計で新規追加する場合、カテゴリ・タグ・ファイルを個別に追加し、メモ追加時にそれらを添付するシステムになるが、メモ追加時に全て追加出来た方がユーザー体験としては良いと考えてmemo内に処理を記載している
- ユーザーはメモ作成時に必要な情報を一度に完結できるため、操作の手間が減り、直感的に利用できると考えます
- 一方で、コンポーネント肥大化やProp Drillingになりやすく、複数の機能が密接に連携することで関心事の分離が曖昧になり複雑性も増すので、設計どおり個別実装した方が保守管理はしやすいです
- アカウントとプロフィールにも同様のことがいえます
- 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から認証リンクを取得しテストを行っています。
- アドレス変更は専用アカウントは作成せず、変更後に元に戻すテストをしており、これによって認証用テストアカウントは一つで済みますので運用がシンプルになります。
- UIコンポーネントライブラリをプロジェクトにコピーして使用できるので便利ですが、DOMは調べる必要があります。
- テスト時にはdata-testIdなどをつけるか実際のDOMを見るかする必要があります。
- 基本的にはブラウザのdev toolで事足りる。
- storybookの様なUIコンポーネントカタログを作成するのも良いが、DOM見るだけに導入する事は避ける方が良い。導入したら保守・運用が発生するコストと見合うかどうかを検討する必要があります。
## 今後の課題
- オブザーバビリティ可観測性(sentry APM検討)
- openAPI(zod-openAPI検討)