Remix のデータ境界設計:Loader・Action とクライアントコードの責務分離
Remix(React Router 7)でアプリケーションを構築する際、「どの処理をどこに書くべきか」という判断は設計の要です。Loader・Action・クライアントコードの責務を明確に分離しないと、セキュリティリスクや保守性の低下を招きます。本記事では、実際のプロジェクトで試行錯誤した経験をもとに、データ境界の設計指針と判断基準を解説します。
Loader・Action・クライアントコードの責務比較
| 項目 | Loader | Action | クライアントコード |
|---|---|---|---|
| 実行環境 | サーバーのみ | サーバーのみ | ブラウザのみ |
| 主な役割 | データ取得(GET) | データ変更(POST/PUT/DELETE) | UI 操作・状態管理 |
| 実行タイミング | ルート遷移時 | フォーム送信時 | ユーザー操作時 |
| 機密情報の扱い | 安全に扱える | 安全に扱える | 扱ってはならない |
| DB アクセス | 可能 | 可能 | 不可(API 経由のみ) |
| バンドルサイズ | 影響なし | 影響なし | 直接影響する |
それぞれの詳細は後述します。
検証環境
- OS: macOS Sequoia 15.3
- Node.js: 24.13.0
- TypeScript: 5.9.3
- 主要パッケージ:
- @remix-run/node: 2.17.2
- @remix-run/react: 2.17.2
- react: 19.0.0
- 検証日: 2026 年 01 月 26 日
データ境界設計が重要になった背景
この章では、Remix のアーキテクチャがなぜ「境界」を重視するのか、その技術的・実務的な理由を説明します。
サーバーとクライアントの曖昧な境界が招く混乱
従来の React アプリケーションでは、すべてのコードがクライアントサイドで実行されることを前提に設計されていました。サーバーサイドレンダリング(SSR)を導入すると、同じコードがサーバーでもクライアントでも動く「ユニバーサル」な設計が求められます。
しかし、この「どこでも動く」という特性が問題を生みます。
typescript// ❌ 危険な例:クライアントにも露出するコンポーネント内で機密情報を扱う
function UserProfile() {
const apiKey = process.env.API_SECRET_KEY; // ビルド時にバンドルされる可能性
// ...
}
つまずきやすい点:
process.envの変数は、プレフィックスや設定によってはクライアントバンドルに含まれることがあります。
Remix が採用したモデル駆動の境界設計
Remix は「サーバー専用関数」として loader と action を明確に定義しました。これらの関数は絶対にクライアントバンドルに含まれません。この設計により、開発者は「ここに書けばサーバーでしか動かない」という確信を持ってコードを書けます。
以下の図は、Remix におけるデータの流れを示しています。
mermaidflowchart LR
subgraph Server["サーバー"]
loader["Loader<br/>(データ取得)"]
action["Action<br/>(データ変更)"]
db["データベース"]
end
subgraph Client["ブラウザ"]
component["React コンポーネント"]
form["Form"]
end
db --> loader
loader --> component
form --> action
action --> db
action --> loader
この図は、Loader がデータベースからデータを取得しコンポーネントへ渡す流れと、Form から Action を経由してデータを変更する流れを表しています。
責務分離を怠った場合に発生する問題
この章では、実際のプロジェクトで経験した失敗事例と、それぞれの問題がどのようなリスクにつながるかを解説します。
機密情報のクライアント漏洩
実際に試したところ、クライアントコンポーネント内で環境変数を参照するコードを書いた際、ビルド後のバンドルに API キーが含まれていたことがありました。
typescript// ❌ 実際に問題になったコード
export default function PaymentPage() {
const stripeKey = process.env.STRIPE_SECRET_KEY;
// このキーがバンドルに含まれ、ブラウザの開発者ツールで確認できてしまった
}
この問題は、Loader を使うことで解決できます。
typescript// ✅ Loader で安全に処理
export async function loader() {
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const session = await stripe.checkout.sessions.create({
/* ... */
});
return json({ sessionId: session.id }); // 公開可能な ID のみ返す
}
バンドルサイズの肥大化
検証の結果、サーバー専用のライブラリをクライアントコードで import すると、そのライブラリ全体がバンドルに含まれることがわかりました。
typescript// ❌ クライアントバンドルが肥大化する例
import { PrismaClient } from '@prisma/client'; // 数 MB のライブラリ
export default function UserList() {
// Prisma はサーバーでしか使えないが、import 文でバンドルに含まれる
}
Loader 内で import することで、この問題を回避できます。
認証・認可の不整合
業務で問題になったケースとして、クライアントサイドで認証状態を判定し、それに基づいてデータを取得するロジックがありました。
typescript// ❌ クライアントで認証判定すると偽装可能
export default function AdminDashboard() {
const [isAdmin, setIsAdmin] = useState(false);
useEffect(() => {
// localStorage の値は改ざん可能
setIsAdmin(localStorage.getItem("role") === "admin");
}, []);
if (!isAdmin) return <div>権限がありません</div>;
// ...
}
サーバーサイドで認証を行う Loader を使えば、この脆弱性を防げます。
責務分離の設計指針と判断基準
この章では、どの処理をどこに書くべきかの判断基準を、採用理由と不採用理由を含めて解説します。
Loader に書くべき処理
Loader は HTTP GET リクエストに対応し、データの「読み取り」を担当します。
採用すべきケース:
- データベースからのデータ取得
- 外部 API からのデータフェッチ
- 認証状態の検証とリダイレクト
- 環境変数・機密情報を使う処理
typescriptimport { json, redirect } from '@remix-run/node';
import type { LoaderFunctionArgs } from '@remix-run/node';
import { getSession } from '~/sessions.server';
import { db } from '~/db.server';
export async function loader({
request,
}: LoaderFunctionArgs) {
const session = await getSession(
request.headers.get('Cookie')
);
const userId = session.get('userId');
if (!userId) {
return redirect('/login');
}
const user = await db.user.findUnique({
where: { id: userId },
select: { id: true, name: true, email: true }, // 必要なフィールドのみ
});
return json({ user });
}
注意点:Loader で返すデータは、そのままクライアントに渡されます。パスワードハッシュなど、クライアントに渡すべきでないフィールドは select で除外してください。
Action に書くべき処理
Action は HTTP POST/PUT/DELETE リクエストに対応し、データの「変更」を担当します。
採用すべきケース:
- データベースへの書き込み・更新・削除
- フォームバリデーション(サーバーサイド)
- 外部サービスへのデータ送信
- セッションの作成・破棄
typescriptimport { json, redirect } from '@remix-run/node';
import type { ActionFunctionArgs } from '@remix-run/node';
import { db } from '~/db.server';
import { validatePost } from '~/validators.server';
export async function action({
request,
}: ActionFunctionArgs) {
const formData = await request.formData();
const title = formData.get('title');
const content = formData.get('content');
// サーバーサイドバリデーション
const errors = validatePost({ title, content });
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
const post = await db.post.create({
data: {
title: String(title),
content: String(content),
},
});
return redirect(`/posts/${post.id}`);
}
採用しなかった代替案:API Routes を別途作成してクライアントから fetch する方法も検討しましたが、Remix の Form コンポーネントとの連携やプログレッシブエンハンスメントの恩恵を受けられないため不採用としました。
クライアントコードに書くべき処理
クライアントコードは、ブラウザ固有の API や即時のフィードバックが必要な処理を担当します。
採用すべきケース:
- UI の状態管理(モーダルの開閉、タブの切り替え)
- フォーム入力のリアルタイムバリデーション(UX 向上目的)
- アニメーション・トランジション
- ブラウザ API(localStorage、Geolocation など)の利用
typescriptimport { useState } from "react";
import { useLoaderData, Form } from "@remix-run/react";
export default function PostEditor() {
const { post } = useLoaderData<typeof loader>();
const [isPreview, setIsPreview] = useState(false);
const [title, setTitle] = useState(post?.title ?? "");
// クライアントサイドのリアルタイムバリデーション(UX 向上目的)
const titleError = title.length > 100 ? "100 文字以内で入力してください" : null;
return (
<div>
<button onClick={() => setIsPreview(!isPreview)}>
{isPreview ? "編集" : "プレビュー"}
</button>
{isPreview ? (
<Preview title={title} />
) : (
<Form method="post">
<input
name="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
/>
{titleError && <span>{titleError}</span>}
<button type="submit">保存</button>
</Form>
)}
</div>
);
}
ポイント:クライアントサイドのバリデーションは UX 向上のためのものです。サーバーサイドでのバリデーションは必須であり、省略してはなりません。
実践的なコード例:ユーザー管理機能
この章では、Loader・Action・クライアントコードを適切に分離した実装例を示します。
ファイル構成
bashapp/
├── routes/
│ └── users/
│ ├── _index.tsx # ユーザー一覧
│ └── $userId.tsx # ユーザー詳細・編集
├── models/
│ └── user.server.ts # サーバー専用のデータアクセス層
└── components/
└── UserForm.tsx # 再利用可能な UI コンポーネント
データアクセス層(サーバー専用)
.server.ts サフィックスをつけることで、このファイルがクライアントバンドルに含まれないことが保証されます。
typescript// app/models/user.server.ts
import { db } from '~/db.server';
import bcrypt from 'bcryptjs';
export async function getUsers() {
return db.user.findMany({
select: {
id: true,
name: true,
email: true,
createdAt: true,
},
orderBy: { createdAt: 'desc' },
});
}
export async function getUserById(id: string) {
return db.user.findUnique({
where: { id },
select: {
id: true,
name: true,
email: true,
role: true,
},
});
}
export async function updateUser(
id: string,
data: { name?: string; email?: string }
) {
return db.user.update({
where: { id },
data,
});
}
ルートファイル(Loader・Action・コンポーネント)
typescript// app/routes/users/$userId.tsx
import { json, redirect } from "@remix-run/node";
import type { LoaderFunctionArgs, ActionFunctionArgs } from "@remix-run/node";
import { useLoaderData, useActionData, Form, useNavigation } from "@remix-run/react";
import { useState } from "react";
import { getUserById, updateUser } from "~/models/user.server";
import { requireAdmin } from "~/sessions.server";
// Loader: サーバーでのみ実行されるデータ取得
export async function loader({ request, params }: LoaderFunctionArgs) {
await requireAdmin(request); // 管理者権限の検証
const user = await getUserById(params.userId!);
if (!user) {
throw new Response("ユーザーが見つかりません", { status: 404 });
}
return json({ user });
}
// Action: サーバーでのみ実行されるデータ変更
export async function action({ request, params }: ActionFunctionArgs) {
await requireAdmin(request);
const formData = await request.formData();
const name = formData.get("name");
const email = formData.get("email");
const errors: Record<string, string> = {};
if (typeof name !== "string" || name.length < 1) {
errors.name = "名前は必須です";
}
if (typeof email !== "string" || !email.includes("@")) {
errors.email = "有効なメールアドレスを入力してください";
}
if (Object.keys(errors).length > 0) {
return json({ errors }, { status: 400 });
}
await updateUser(params.userId!, {
name: String(name),
email: String(email),
});
return redirect("/users");
}
// コンポーネント: クライアントで実行される UI
export default function UserDetail() {
const { user } = useLoaderData<typeof loader>();
const actionData = useActionData<typeof action>();
const navigation = useNavigation();
const isSubmitting = navigation.state === "submitting";
// UI 状態管理はクライアントの責務
const [isEditing, setIsEditing] = useState(false);
if (!isEditing) {
return (
<div>
<h1>{user.name}</h1>
<p>{user.email}</p>
<button onClick={() => setIsEditing(true)}>編集</button>
</div>
);
}
return (
<Form method="post">
<div>
<label htmlFor="name">名前</label>
<input id="name" name="name" defaultValue={user.name} />
{actionData?.errors?.name && (
<span className="error">{actionData.errors.name}</span>
)}
</div>
<div>
<label htmlFor="email">メールアドレス</label>
<input id="email" name="email" type="email" defaultValue={user.email} />
{actionData?.errors?.email && (
<span className="error">{actionData.errors.email}</span>
)}
</div>
<button type="submit" disabled={isSubmitting}>
{isSubmitting ? "保存中..." : "保存"}
</button>
<button type="button" onClick={() => setIsEditing(false)}>
キャンセル
</button>
</Form>
);
}
以下の図は、この実装におけるデータフローを示しています。
mermaidsequenceDiagram
participant Browser as ブラウザ
participant Loader as Loader
participant Action as Action
participant DB as データベース
Browser->>Loader: ページ遷移(GET)
Loader->>DB: ユーザー取得
DB-->>Loader: ユーザーデータ
Loader-->>Browser: JSON レスポンス
Browser->>Action: フォーム送信(POST)
Action->>Action: バリデーション
Action->>DB: ユーザー更新
DB-->>Action: 更新結果
Action-->>Browser: リダイレクト
この図は、ページ遷移時の Loader によるデータ取得と、フォーム送信時の Action によるデータ更新の流れを表しています。
よくある設計ミスと対処法
この章では、実務でよく見かける設計ミスとその修正方法を紹介します。
useEffect でデータをフェッチする
Remix を使っているにもかかわらず、従来の React の習慣で useEffect 内でデータをフェッチするコードを見かけます。
typescript// ❌ Remix の恩恵を受けられない書き方
export default function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch("/api/users")
.then((res) => res.json())
.then((data) => {
setUsers(data);
setLoading(false);
});
}, []);
if (loading) return <div>読み込み中...</div>;
// ...
}
typescript// ✅ Loader を使った書き方
export async function loader() {
const users = await getUsers();
return json({ users });
}
export default function UserList() {
const { users } = useLoaderData<typeof loader>();
// ローディング状態は Remix が管理
// ...
}
Loader を使うメリット:
- SSR 時にデータが含まれた状態でレンダリングされる
- プリフェッチが可能になる
- ローディング状態の管理が不要になる
- エラーハンドリングが統一される
クライアントで認可チェックを行う
typescript// ❌ クライアントでの認可チェックは回避可能
export default function AdminPage() {
const { user } = useLoaderData<typeof loader>();
if (user.role !== "admin") {
return <Navigate to="/" />;
}
// ...
}
typescript// ✅ Loader で認可チェック
export async function loader({
request,
}: LoaderFunctionArgs) {
const user = await requireUser(request);
if (user.role !== 'admin') {
throw new Response('権限がありません', { status: 403 });
}
return json({ user });
}
責務分離の詳細判断基準
この章では、各処理をどこに配置すべきかの詳細な判断基準をまとめます。
処理別の配置先一覧
| 処理 | Loader | Action | クライアント | 理由 |
|---|---|---|---|---|
| DB からのデータ取得 | ✅ | - | ❌ | DB 接続情報の保護 |
| DB へのデータ書き込み | - | ✅ | ❌ | トランザクション管理 |
| 認証状態の検証 | ✅ | ✅ | ❌ | セッション情報の保護 |
| 環境変数の参照 | ✅ | ✅ | ❌ | 機密情報の保護 |
| フォームバリデーション | - | ✅ | △ | サーバーは必須、クライアントは UX 向上目的 |
| UI 状態の管理 | - | - | ✅ | ブラウザ固有の操作 |
| アニメーション | - | - | ✅ | DOM 操作が必要 |
| localStorage 操作 | - | - | ✅ | ブラウザ API |
| リダイレクト | ✅ | ✅ | △ | サーバーサイドが確実 |
境界判断のフローチャート
mermaidflowchart TD
Start["処理を追加したい"] --> Q1{"機密情報を<br/>扱うか?"}
Q1 -->|Yes| Server["Loader または Action"]
Q1 -->|No| Q2{"データの<br/>読み書きか?"}
Q2 -->|読み取り| Loader2["Loader"]
Q2 -->|書き込み| Action2["Action"]
Q2 -->|No| Q3{"ブラウザ API<br/>が必要か?"}
Q3 -->|Yes| Client["クライアントコード"]
Q3 -->|No| Q4{"即時フィードバック<br/>が必要か?"}
Q4 -->|Yes| Client
Q4 -->|No| Server
このフローチャートは、新しい処理を追加する際に、どこに配置すべきかを判断するための指針を示しています。
ケース別の判断例
ケース 1: 検索機能
- 検索結果の取得 → Loader(URL パラメータで検索条件を受け取る)
- 検索フォームの入力状態 → クライアント(デバウンス処理など)
- 検索履歴の保存 → Action(DB に保存する場合)
ケース 2: ファイルアップロード
- ファイルの受信と保存 → Action
- アップロード進捗の表示 → クライアント
- ファイル一覧の取得 → Loader
ケース 3: リアルタイム通知
- 通知データの初期取得 → Loader
- WebSocket 接続の管理 → クライアント
- 通知の既読処理 → Action
まとめ
Remix における Loader・Action・クライアントコードの責務分離は、単なるコーディング規約ではありません。セキュリティ、パフォーマンス、保守性のすべてに影響する設計判断です。
本記事で解説した判断基準を改めて整理します。
- Loader: データの読み取り、認証・認可の検証、機密情報を扱う処理
- Action: データの書き込み、フォームバリデーション、副作用を伴う処理
- クライアントコード: UI 状態管理、即時フィードバック、ブラウザ API の利用
ただし、これらの基準は絶対的なものではありません。プロジェクトの要件やチームの習熟度に応じて、適切なバランスを見つけることが重要です。
迷ったときは「この処理に機密情報が含まれるか」「この処理はサーバーでなければ実行できないか」という問いを立てることで、適切な配置先が見えてくるはずです。
関連リンク
著書
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
article2025年12月28日RemixとTypeScriptのユースケース 型安全なフルスタック開発を設計して進める
articleRemix Loader/Action チートシート:Request/Response API 逆引き大全
articleRemix × Vite 構成の作り方:開発サーバ・ビルド・エイリアス設定【完全ガイド】
articleRemix を選ぶ基準:認証・API・CMS 観点での要件適合チェック
articleRemix の仕組みを図解で理解:ルーティング/データロード/アクションの全体像
articleRemix のデータ境界設計:Loader・Action とクライアントコードの責務分離
articlePreact コンポーネント設計 7 原則:再レンダリング最小化の分割と型付け
articlePHP 8.3 の新機能まとめ:readonly クラス・型強化・性能改善を一気に理解
articleNotebookLM に PDF/Google ドキュメント/URL を取り込む手順と最適化
articlePlaywright 並列実行設計:shard/grep/fixtures で高速化するテストスイート設計術
article2026年1月23日TypeScriptのtypeとinterfaceを比較・検証する 違いと使い分けの判断基準を整理
blogiPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
blogGoogleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
blog【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
blogGoogleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
blogPixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
blogフロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
review今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
reviewついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
review愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
review週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
review新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
review科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来
