T-CREATOR

<div />

Remix のデータ境界設計:Loader・Action とクライアントコードの責務分離

Remix のデータ境界設計:Loader・Action とクライアントコードの責務分離

Remix(React Router 7)でアプリケーションを構築する際、「どの処理をどこに書くべきか」という判断は設計の要です。Loader・Action・クライアントコードの責務を明確に分離しないと、セキュリティリスクや保守性の低下を招きます。本記事では、実際のプロジェクトで試行錯誤した経験をもとに、データ境界の設計指針と判断基準を解説します。

Loader・Action・クライアントコードの責務比較

項目LoaderActionクライアントコード
実行環境サーバーのみサーバーのみブラウザのみ
主な役割データ取得(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 は「サーバー専用関数」として loaderaction を明確に定義しました。これらの関数は絶対にクライアントバンドルに含まれません。この設計により、開発者は「ここに書けばサーバーでしか動かない」という確信を持ってコードを書けます。

以下の図は、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 });
}

責務分離の詳細判断基準

この章では、各処理をどこに配置すべきかの詳細な判断基準をまとめます。

処理別の配置先一覧

処理LoaderActionクライアント理由
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 の利用

ただし、これらの基準は絶対的なものではありません。プロジェクトの要件やチームの習熟度に応じて、適切なバランスを見つけることが重要です。

迷ったときは「この処理に機密情報が含まれるか」「この処理はサーバーでなければ実行できないか」という問いを立てることで、適切な配置先が見えてくるはずです。

関連リンク

著書

とあるクリエイター

フロントエンドエンジニア Next.js / React / TypeScript / Node.js / Docker / AI Coding

;