前編では、Webアプリの全体像と、土台をBaaSに任せるところまでを地図として描きました。後編は、その土台の上で必ず向き合うことになる3つの実装に踏み込みます。DBのアクセス制御、画面の状態管理、そしてリアルタイム更新です。どれも「動くものは作れたが、本番に出す前に詰まる」典型でした。
私自身、個人開発でアプリの裏側を組むとき、この3つでそれぞれ別のハマり方をしました。順に、コードを交えて整理します。
なぜ「誰でもDBを直接叩ける」のは危険なのか
BaaSの便利さの裏返しが、ブラウザから直接DBを呼べてしまうことです。これは「悪意のある人が他人のデータも見えるのでは」という当然の不安につながります。その不安は正しく、ここで効くのが RLS(Row Level Security、行レベルセキュリティ)です。
RLSは「テーブルの各行に対して、誰がアクセスできるかをDB自体に設定する」機能です。クエリ自体は誰が送っても同じですが、RLSが送信者を見て、返す行を自動で絞り込みます。
-- 「自分が作成したタスクしか見えない」をDBレベルで強制する
alter table tasks enable row level security;
create policy "自分のタスクのみ参照可能"
on tasks for select
using (auth.uid() = owner_id);
普通のプログラミングでは「アクセス制御はアプリのコード(if文)で書く」と考えがちですが、BaaSの世界ではDB自体にルールを持たせます。これにより、フロントのコードにバグがあっても、DBレベルで他人のデータの漏洩を防げます。私のお勧めは、「テーブルを作ったらRLSを有効にしてポリシーを書く」までを必ず1セットの作業にすることです。後回しにすると、そのまま本番に出て事故になります。
認証とRLSは同じトークンで連動している
RLSのポリシーに出てきた auth.uid() の中身は、ログイン状態を表すトークンそのものです。ログイン機能を自作するのは大変ですが、BaaSを使うとライブラリのように扱えます。
// ログイン
const { data, error } = await supabase.auth.signInWithPassword({
email: 'user@example.com',
password: 'password123',
});
// 現在ログイン中のユーザーを取得
const { data: { user } } = await supabase.auth.getUser();
知っておくと整理しやすいのは、ログイン状態がブラウザに保存されるトークンで管理されている、という点です。ログイン時に受け取ったトークンをブラウザが保持し、以降のリクエストで一緒に送ることで、サーバーに「ログイン済みのこのユーザーだ」と伝えます。このトークンが、先ほどのRLSの auth.uid() の中身です。つまり認証とRLSは、同じトークンを通じて連動しています。ここがつながって見えると、アクセス制御の全体像が一気に腑に落ちます。
状態管理は「本当に共有が要るものだけ」グローバルに
Reactでは、コンポーネントごとに状態を持てます。ただ、複数の画面で同じデータ(例えばログイン中のユーザー情報)を共有したいとき、これだけだと不便です。propsをバケツリレーのように何階層も渡し続けることになります。
これを解決するのがグローバルな状態管理ライブラリです。Zustand のような軽量なものなら、最小限のコードで「アプリ全体からアクセスできる共有の置き場所」を用意できます。
// lib/store/user.ts
import { create } from 'zustand';
export const useUserStore = create((set) => ({
user: null,
setUser: (user) => set({ user }),
}));
// どのコンポーネントからでも直接呼べる(バケツリレー不要)
function UserMenu() {
const user = useUserStore((state) => state.user);
return <div>{user?.name}</div>;
}
使い分けの基準は、「そのデータを複数の離れたコンポーネントで共有する必要があるか」です。Yesならグローバル状態、Noなら通常のローカル状態で十分です。最初から全部グローバルにすると、逆に「どこでデータが変わったか追いづらい」状態になるので、ここは欲張らないことをお勧めします。私も初期に全部グローバル化して、後から追いきれなくなった経験があります。
リアルタイム更新の正体と、購読解除の落とし穴
チャットやコラボツールのように「他の人の操作が自分の画面にも反映される」機能は、最初は不思議に見えます。仕組みは、通常のHTTP通信が「ブラウザがサーバーに聞きに行く」一方通行なのに対し、WebSocket という別の通路を使い、サーバー側から能動的にブラウザへデータを送れる状態を作る、というものです。
Supabase の Realtime を使うと、DBの変更を購読して画面を自動更新できます。
useEffect(() => {
const channel = supabase
.channel('tasks-changes')
.on('postgres_changes',
{ event: '*', schema: 'public', table: 'tasks' },
(payload) => {
// ここで画面の状態を更新する
})
.subscribe();
return () => {
supabase.removeChannel(channel); // 画面を離れたら必ず購読を解除する
};
}, []);
ここで本番に出す前に必ず潰すべき落とし穴が、購読の解除忘れです。useEffect の中で購読を始めたら、コンポーネントが画面から消えるときに必ず解除する処理を書く必要があります。忘れると、ページを訪れるたびに通路が増え続け、メモリやコネクションを無駄に消費するメモリリークになります。私はこれを見落として、開発中に挙動が重くなった原因を探すのに時間を溶かしました。クリーンアップ関数を書くまでが購読の一手だ、と覚えておくのが回避策です。
フォームのバリデーションは型と分けて考える
ユーザーの入力を受け取るフォームでは、「入力値が正しい形か」を必ずチェックします。これを手書きの条件分岐でやると膨らむので、スキーマを先に定義して自動でチェックするライブラリ(Zodなど)がよく使われます。
import { z } from 'zod';
const taskSchema = z.object({
title: z.string().min(1, 'タイトルは必須です').max(100),
dueDate: z.date().optional(),
});
const result = taskSchema.safeParse(formData);
if (!result.success) {
console.log(result.error.issues); // どこがおかしいかが分かる
}
ここで押さえたいのは、TypeScriptの型と、実行時のバリデーションは別物だという点です。型はコードを書く時点のチェックしかしてくれず、ユーザーが実際に何を入力してくるかは実行するまで分かりません。Zodのようなライブラリは、実行時に実際の値が正しいかを検証する役割を担います。本番では外部から想定外の値が来る前提で、この実行時チェックを必ず一枚挟むことを推奨します。
本番前のチェックリストとして
最後に、ここまでを実装の順序として整理します。テーブルを作ったらRLSを有効にしてポリシーを書く。状態は本当に共有が要るものだけグローバルにする。リアルタイム購読は必ずクリーンアップとセットで書く。フォームは型とは別に実行時バリデーションを挟む。この4つを最初から1セットの習慣にしておくと、後から事故を掘り返す手間が大きく減ります。
専門用語は多く見えますが、一つずつは過去の知識をWebの文脈に翻訳したものです。私自身、最初の一本でこの3つに順番にハマり、その都度仕組みを理解して乗り越えました。同じ場所で詰まっている方が、地図を片手に一歩進む助けになればうれしいです。まずは小さなアプリに、RLSの有効化を1つ足すところから始めてみてください。