T-CREATOR

Remix の Loader/Action を使いこなす実践 Tips

Remix の Loader/Action を使いこなす実践 Tips

Remix フレームワークでアプリケーションを開発する際、最も重要な概念の一つが Loader(ローダー)と Action(アクション)です。これらは Remix アプリケーションの心臓部とも言える機能で、サーバーサイドでのデータ処理とクライアントサイドでのユーザーインターフェースを効果的に結びつける役割を担っています。

本記事では、Remix の Loader と Action を基礎から応用まで段階的に学び、実際のプロジェクトで活用できる実践的なテクニックをご紹介します。初心者の方でも理解しやすいよう、具体的なコード例とともに詳しく解説していきますね。

背景

Remix における Loader/Action の位置づけ

Remix フレームワークにおける Loader と Action は、Web 標準に基づいたフルスタック開発を実現するための核心的な機能です。これらは従来のクライアントサイドレンダリング(CSR)とサーバーサイドレンダリング(SSR)の良いところを組み合わせ、パフォーマンスとユーザビリティを両立させる役割を果たします。

Loader はページやコンポーネントに必要なデータをサーバーサイドで取得し、Action はフォーム送信やデータ更新などのユーザーアクションを処理します。これらの機能により、開発者はサーバーとクライアントの境界を意識することなく、シームレスな Web 体験を提供できるのです。

従来の React アプリケーションとの違い

従来の React アプリケーションでは、データ取得とフォーム処理を以下のような方法で実装していました:

従来の React アプリケーションRemix アプリケーション
useEffect で API を呼び出しLoader でサーバーサイドデータ取得
State でローディング状態管理自動的なローディング状態提供
onSubmit でフォーム処理Action で宣言的なフォーム処理
手動でエラーハンドリング組み込みエラー境界機能

従来の方法では、クライアントサイドでのデータ取得により初期表示が遅くなったり、SEO に不利になったりする問題がありました。Remix ではサーバーサイドでデータを事前に取得するため、これらの問題を根本的に解決できるのです。

サーバーサイドレンダリングとの関係性

Remix の Loader/Action は、単なる SSR を超えた「Progressive Enhancement」の思想に基づいています。これは、JavaScript が無効でも基本的な機能が動作し、JavaScript が有効な場合により豊かなユーザー体験を提供するアプローチです。

サーバーサイドで処理を行うことで、ネットワーク遅延を最小化し、SEO フレンドリーなアプリケーションを構築できます。同時に、クライアントサイドではハイドレーションにより動的な機能を追加し、シングルページアプリケーション(SPA)のような快適な操作感も実現しているのです。

課題

Loader/Action の使い分けが分からない

多くの開発者が直面する最初の壁は、いつ Loader を使い、いつ Action を使うべきかの判断です。この混乱は、従来の React 開発パターンと Remix の宣言的なアプローチの違いに起因しています。

適切な使い分けができないと、不必要な複雑さが生まれたり、パフォーマンスが低下したりする可能性があります。例えば、データ取得に Action を使ってしまったり、単純な表示処理に Loader を多用してしまったりするケースが見受けられますね。

データの取得とフォーム処理の最適化

Web アプリケーションの性能は、データの取得とフォーム処理の効率性に大きく依存します。特に以下のような問題が頻繁に発生します:

  • データ取得の重複: 同じデータを複数回取得してしまう
  • 不要な再レンダリング: データ更新時に関係のないコンポーネントまで再レンダリングされる
  • ローディング時間の長さ: ユーザーが待ち時間を感じてしまう

これらの問題を解決するには、Loader/Action の特性を理解し、適切なキャッシュ戦略とデータフロー設計が必要です。

エラーハンドリングとローディング状態の管理

従来の React 開発では、エラーハンドリングとローディング状態の管理は開発者が手動で実装する必要がありました。しかし、適切な実装には多くの考慮事項があります:

  • エラーの種類別処理: ネットワークエラー、サーバーエラー、バリデーションエラーなど
  • ユーザーフィードバック: 適切なエラーメッセージとローディング表示
  • エラー回復: エラー発生後のユーザーアクション設計

Remix では、これらの処理を組み込み機能として提供していますが、効果的に活用するにはそれぞれの特性と最適な使用方法を理解する必要があるのです。

解決策

Loader の基本的な使い方

データ取得の基本パターン

Loader は、ページコンポーネントにデータを提供するための関数です。基本的な実装パターンを見てみましょう。

typescript// app/routes/posts._index.tsx

import {
  json,
  type LoaderFunctionArgs,
} from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

// Loader関数の定義
export async function loader({
  request,
}: LoaderFunctionArgs) {
  // データベースまたはAPIからデータを取得
  const posts = await fetchPosts();

  return json({ posts });
}

次に、取得したデータをコンポーネントで使用する方法です:

typescript// 同じファイル内のコンポーネント部分

export default function PostsIndex() {
  // Loaderから取得したデータを使用
  const { posts } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>ブログ記事一覧</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <h2>{post.title}</h2>
            <p>{post.excerpt}</p>
          </li>
        ))}
      </ul>
    </div>
  );
}

この基本パターンでは、ページが読み込まれる前にサーバーサイドでデータを取得し、初期表示時にはすでにデータが利用可能になっています。

非同期データの扱い方

複数のデータソースから並行してデータを取得する場合の実装方法です:

typescript// app/routes/dashboard.tsx

export async function loader({
  request,
}: LoaderFunctionArgs) {
  // 複数のAPIを並行して呼び出し
  const [user, posts, analytics] = await Promise.all([
    fetchUser(getUserId(request)),
    fetchRecentPosts(),
    fetchAnalytics(),
  ]);

  return json({
    user,
    posts,
    analytics,
    timestamp: new Date().toISOString(),
  });
}

エラーハンドリングを含めた堅牢な実装:

typescriptexport async function loader({
  request,
}: LoaderFunctionArgs) {
  try {
    const userId = getUserId(request);

    // 必須データと任意データを分けて処理
    const user = await fetchUser(userId);

    const [posts, analytics] = await Promise.allSettled([
      fetchRecentPosts(userId),
      fetchAnalytics(userId),
    ]);

    return json({
      user,
      posts:
        posts.status === 'fulfilled' ? posts.value : [],
      analytics:
        analytics.status === 'fulfilled'
          ? analytics.value
          : null,
    });
  } catch (error) {
    throw new Response(
      'ユーザーデータの取得に失敗しました',
      {
        status: 500,
      }
    );
  }
}
パフォーマンス最適化のコツ

データ取得の効率を向上させるための実装テクニックです:

typescript// app/routes/products.$category.tsx

export async function loader({
  request,
  params,
}: LoaderFunctionArgs) {
  const url = new URL(request.url);
  const page = parseInt(
    url.searchParams.get('page') || '1'
  );
  const limit = 20;
  const offset = (page - 1) * limit;

  // ページネーション付きでデータを取得
  const [products, totalCount] = await Promise.all([
    fetchProducts({
      category: params.category,
      limit,
      offset,
    }),
    getProductCount(params.category),
  ]);

  return json({
    products,
    pagination: {
      currentPage: page,
      totalPages: Math.ceil(totalCount / limit),
      totalCount,
    },
  });
}

キャッシュヘッダーを活用したパフォーマンス向上:

typescriptexport async function loader({
  request,
  params,
}: LoaderFunctionArgs) {
  const products = await fetchProducts(params.category);

  return json(
    { products },
    {
      headers: {
        'Cache-Control':
          'public, max-age=300, s-maxage=3600',
        Vary: 'Accept-Language',
      },
    }
  );
}

Action の効果的な活用法

フォーム処理の実装方法

Action は、フォーム送信やデータ更新処理を行うための関数です。基本的な実装から見ていきましょう:

typescript// app/routes/posts.new.tsx

import {
  redirect,
  json,
  type ActionFunctionArgs,
} from '@remix-run/node';

// Action関数でフォーム処理を行う
export async function action({
  request,
}: ActionFunctionArgs) {
  const formData = await request.formData();

  const postData = {
    title: formData.get('title') as string,
    content: formData.get('content') as string,
    authorId: getUserId(request),
  };

  // データベースに新しい投稿を保存
  const newPost = await createPost(postData);

  // 作成後は詳細ページにリダイレクト
  return redirect(`/posts/${newPost.id}`);
}

フォームコンポーネントの実装:

typescriptimport {
  Form,
  useActionData,
  useNavigation,
} from '@remix-run/react';

export default function NewPost() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting =
    navigation.formAction === '/posts/new';

  return (
    <Form method='post'>
      <div>
        <label htmlFor='title'>タイトル</label>
        <input
          type='text'
          id='title'
          name='title'
          required
          disabled={isSubmitting}
        />
      </div>

      <div>
        <label htmlFor='content'>本文</label>
        <textarea
          id='content'
          name='content'
          required
          disabled={isSubmitting}
        />
      </div>

      <button type='submit' disabled={isSubmitting}>
        {isSubmitting ? '投稿中...' : '投稿する'}
      </button>
    </Form>
  );
}
バリデーションとエラーハンドリング

サーバーサイドでのバリデーション実装:

typescript// app/routes/posts.new.tsx

import { json, redirect } from '@remix-run/node';

interface ActionData {
  errors?: {
    title?: string;
    content?: string;
  };
  values?: {
    title: string;
    content: string;
  };
}

export async function action({
  request,
}: ActionFunctionArgs): Promise<Response> {
  const formData = await request.formData();

  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // バリデーション処理
  const errors: ActionData['errors'] = {};

  if (!title || title.length < 1) {
    errors.title = 'タイトルは必須です';
  } else if (title.length > 100) {
    errors.title =
      'タイトルは100文字以内で入力してください';
  }

  if (!content || content.length < 10) {
    errors.content = '本文は10文字以上で入力してください';
  }

  // エラーがある場合はフォームに戻す
  if (Object.keys(errors).length > 0) {
    return json<ActionData>(
      {
        errors,
        values: { title, content },
      },
      { status: 400 }
    );
  }

  // バリデーション成功時の処理
  try {
    const newPost = await createPost({
      title,
      content,
      authorId: getUserId(request),
    });

    return redirect(`/posts/${newPost.id}`);
  } catch (error) {
    return json<ActionData>(
      {
        errors: {
          content:
            '投稿の作成に失敗しました。もう一度お試しください。',
        },
        values: { title, content },
      },
      { status: 500 }
    );
  }
}

エラー表示を含むフォームコンポーネント:

typescriptexport default function NewPost() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();
  const isSubmitting =
    navigation.formAction === '/posts/new';

  return (
    <Form method='post'>
      <div>
        <label htmlFor='title'>タイトル</label>
        <input
          type='text'
          id='title'
          name='title'
          defaultValue={actionData?.values?.title}
          disabled={isSubmitting}
          aria-invalid={
            actionData?.errors?.title ? true : undefined
          }
          aria-describedby={
            actionData?.errors?.title
              ? 'title-error'
              : undefined
          }
        />
        {actionData?.errors?.title && (
          <p id='title-error' className='error'>
            {actionData.errors.title}
          </p>
        )}
      </div>

      <div>
        <label htmlFor='content'>本文</label>
        <textarea
          id='content'
          name='content'
          defaultValue={actionData?.values?.content}
          disabled={isSubmitting}
          aria-invalid={
            actionData?.errors?.content ? true : undefined
          }
          aria-describedby={
            actionData?.errors?.content
              ? 'content-error'
              : undefined
          }
        />
        {actionData?.errors?.content && (
          <p id='content-error' className='error'>
            {actionData.errors.content}
          </p>
        )}
      </div>

      <button type='submit' disabled={isSubmitting}>
        {isSubmitting ? '投稿中...' : '投稿する'}
      </button>
    </Form>
  );
}
リダイレクトとレスポンス制御

Action での様々なレスポンス制御パターン:

typescript// app/routes/posts.$postId.edit.tsx

export async function action({
  request,
  params,
}: ActionFunctionArgs) {
  const formData = await request.formData();
  const intent = formData.get('intent') as string;

  switch (intent) {
    case 'update': {
      const updatedPost = await updatePost(params.postId!, {
        title: formData.get('title') as string,
        content: formData.get('content') as string,
      });

      // 更新後は詳細ページにリダイレクト
      return redirect(`/posts/${updatedPost.id}`, {
        headers: {
          'Set-Cookie': await createSuccessMessage(
            '投稿を更新しました'
          ),
        },
      });
    }

    case 'delete': {
      await deletePost(params.postId!);

      // 削除後は一覧ページにリダイレクト
      return redirect('/posts', {
        headers: {
          'Set-Cookie': await createSuccessMessage(
            '投稿を削除しました'
          ),
        },
      });
    }

    case 'draft': {
      await updatePost(params.postId!, { status: 'draft' });

      // 下書き保存時はJSONレスポンス(ページリロードなし)
      return json({
        success: true,
        message: '下書きを保存しました',
      });
    }

    default:
      throw new Response('無効な操作です', { status: 400 });
  }
}

高度なテクニック

キャッシュ戦略の実装

効果的なキャッシュ戦略により、アプリケーションのパフォーマンスを大幅に向上させることができます:

typescript// app/routes/api.posts.$postId.tsx

import { type LoaderFunctionArgs } from '@remix-run/node';

export async function loader({
  request,
  params,
}: LoaderFunctionArgs) {
  const postId = params.postId!;

  // ETagを使用した条件付きリクエスト
  const post = await getPostWithCache(postId);
  const etag = generateETag(post);

  // クライアントのETagと比較
  const ifNoneMatch = request.headers.get('If-None-Match');
  if (ifNoneMatch === etag) {
    return new Response(null, {
      status: 304,
      headers: { ETag: etag },
    });
  }

  return json(
    { post },
    {
      headers: {
        ETag: etag,
        'Cache-Control':
          'public, max-age=60, stale-while-revalidate=300',
        Vary: 'Accept, Accept-Language',
      },
    }
  );
}

Redis を使用したサーバーサイドキャッシュの実装:

typescript// app/lib/cache.server.ts

import Redis from 'ioredis';

const redis = new Redis(process.env.REDIS_URL);

export async function getCachedData<T>(
  key: string,
  fetcher: () => Promise<T>,
  ttl: number = 300
): Promise<T> {
  // キャッシュから取得を試行
  const cached = await redis.get(key);

  if (cached) {
    return JSON.parse(cached);
  }

  // キャッシュにない場合は新しく取得
  const data = await fetcher();

  // キャッシュに保存
  await redis.setex(key, ttl, JSON.stringify(data));

  return data;
}

Loader でのキャッシュ活用:

typescriptexport async function loader({
  params,
}: LoaderFunctionArgs) {
  const posts = await getCachedData(
    `posts:${params.category}`,
    () => fetchPostsByCategory(params.category!),
    600 // 10分間キャッシュ
  );

  return json({ posts });
}
ネストしたルートでの使い方

親子関係のあるルートでのデータ共有パターン:

typescript// app/routes/dashboard.tsx(親ルート)

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const user = await getCurrentUser(request);

  if (!user) {
    throw redirect('/login');
  }

  return json({
    user,
    permissions: await getUserPermissions(user.id),
  });
}

export default function Dashboard() {
  const { user } = useLoaderData<typeof loader>();

  return (
    <div>
      <header>
        <h1>ダッシュボード</h1>
        <p>ようこそ、{user.name}さん</p>
      </header>

      <main>
        <Outlet />
      </main>
    </div>
  );
}

子ルートでの親データ活用:

typescript// app/routes/dashboard.settings.tsx(子ルート)

export async function loader({
  request,
}: LoaderFunctionArgs) {
  const user = await getCurrentUser(request);
  const settings = await getUserSettings(user.id);

  return json({ settings });
}

export async function action({
  request,
}: ActionFunctionArgs) {
  const formData = await request.formData();
  const user = await getCurrentUser(request);

  const updatedSettings = await updateUserSettings(
    user.id,
    {
      theme: formData.get('theme') as string,
      language: formData.get('language') as string,
    }
  );

  return json({ success: true, settings: updatedSettings });
}

export default function DashboardSettings() {
  // 親ルートのデータを取得
  const { user, permissions } = useMatches()[1].data;
  // 自分のLoaderデータを取得
  const { settings } = useLoaderData<typeof loader>();

  return (
    <div>
      <h2>設定</h2>

      {permissions.includes('admin') && (
        <p>管理者権限が有効です</p>
      )}

      <Form method='post'>
        <select name='theme' defaultValue={settings.theme}>
          <option value='light'>ライト</option>
          <option value='dark'>ダーク</option>
        </select>

        <button type='submit'>保存</button>
      </Form>
    </div>
  );
}
TypeScript 連携のベストプラクティス

型安全な Loader/Action の実装パターン:

typescript// app/types/post.ts

export interface Post {
  id: string;
  title: string;
  content: string;
  publishedAt: string | null;
  author: {
    id: string;
    name: string;
  };
}

export interface CreatePostData {
  title: string;
  content: string;
  authorId: string;
}

型定義を活用した Loader 実装:

typescript// app/routes/posts.$postId.tsx

import type {
  LoaderFunctionArgs,
  MetaFunction,
} from '@remix-run/node';
import type { Post } from '~/types/post';

interface LoaderData {
  post: Post;
  relatedPosts: Post[];
}

export async function loader({
  params,
}: LoaderFunctionArgs): Promise<Response> {
  const post = await getPost(params.postId!);

  if (!post) {
    throw new Response('投稿が見つかりません', {
      status: 404,
    });
  }

  const relatedPosts = await getRelatedPosts(post.id);

  return json<LoaderData>({
    post,
    relatedPosts,
  });
}

// Meta関数での型活用
export const meta: MetaFunction<typeof loader> = ({
  data,
}) => {
  if (!data) {
    return [{ title: '投稿が見つかりません' }];
  }

  return [
    { title: data.post.title },
    {
      name: 'description',
      content: data.post.content.substring(0, 150),
    },
  ];
};

export default function PostDetail() {
  const { post, relatedPosts } =
    useLoaderData<typeof loader>();

  return (
    <article>
      <h1>{post.title}</h1>
      <p>投稿者: {post.author.name}</p>
      <div
        dangerouslySetInnerHTML={{ __html: post.content }}
      />

      <aside>
        <h3>関連記事</h3>
        <ul>
          {relatedPosts.map((relatedPost) => (
            <li key={relatedPost.id}>
              <a href={`/posts/${relatedPost.id}`}>
                {relatedPost.title}
              </a>
            </li>
          ))}
        </ul>
      </aside>
    </article>
  );
}

Action での型安全な実装:

typescript// app/routes/posts.new.tsx

import type { ActionFunctionArgs } from '@remix-run/node';
import type { CreatePostData } from '~/types/post';

interface ActionData {
  errors?: {
    [K in keyof CreatePostData]?: string;
  };
  values?: Partial<CreatePostData>;
}

export async function action({
  request,
}: ActionFunctionArgs): Promise<Response> {
  const formData = await request.formData();

  const postData: CreatePostData = {
    title: formData.get('title') as string,
    content: formData.get('content') as string,
    authorId: getUserId(request),
  };

  const validation = validatePostData(postData);

  if (!validation.success) {
    return json<ActionData>(
      {
        errors: validation.errors,
        values: postData,
      },
      { status: 400 }
    );
  }

  const newPost = await createPost(postData);
  return redirect(`/posts/${newPost.id}`);
}

// バリデーション関数
function validatePostData(data: CreatePostData) {
  const errors: ActionData['errors'] = {};

  if (!data.title?.trim()) {
    errors.title = 'タイトルは必須です';
  }

  if (!data.content?.trim()) {
    errors.content = '本文は必須です';
  }

  return {
    success: Object.keys(errors).length === 0,
    errors,
  };
}

具体例

ブログアプリケーションでの実装例

実際のブログアプリケーションを想定した、包括的な実装例をご紹介します。まずは記事一覧ページの実装から見ていきましょう:

typescript// app/routes/blog._index.tsx

import {
  json,
  type LoaderFunctionArgs,
  type MetaFunction,
} from '@remix-run/node';
import {
  useLoaderData,
  useSearchParams,
  Form,
} from '@remix-run/react';
import type { Post } from '~/types/post';

interface LoaderData {
  posts: Post[];
  pagination: {
    currentPage: number;
    totalPages: number;
    hasNext: boolean;
    hasPrev: boolean;
  };
  searchQuery: string | null;
  totalCount: number;
}

export async function loader({
  request,
}: LoaderFunctionArgs): Promise<Response> {
  const url = new URL(request.url);
  const page = Math.max(
    1,
    parseInt(url.searchParams.get('page') || '1')
  );
  const search = url.searchParams.get('search') || null;
  const limit = 10;
  const offset = (page - 1) * limit;

  // 検索クエリがある場合とない場合で分岐
  const [posts, totalCount] = search
    ? await Promise.all([
        searchPosts(search, { limit, offset }),
        getSearchCount(search),
      ])
    : await Promise.all([
        getAllPosts({ limit, offset }),
        getTotalPostCount(),
      ]);

  const totalPages = Math.ceil(totalCount / limit);

  return json<LoaderData>({
    posts,
    pagination: {
      currentPage: page,
      totalPages,
      hasNext: page < totalPages,
      hasPrev: page > 1,
    },
    searchQuery: search,
    totalCount,
  });
}

export const meta: MetaFunction<typeof loader> = ({
  data,
}) => {
  const title = data?.searchQuery
    ? `「${data.searchQuery}」の検索結果 - ブログ`
    : 'ブログ記事一覧';

  return [
    { title },
    {
      name: 'description',
      content: '最新のブログ記事をご覧いただけます',
    },
  ];
};

検索機能とページネーションを含むコンポーネント実装:

typescript// 同じファイル内のコンポーネント部分

export default function BlogIndex() {
  const { posts, pagination, searchQuery, totalCount } =
    useLoaderData<typeof loader>();
  const [searchParams] = useSearchParams();

  return (
    <div className='blog-container'>
      <header className='blog-header'>
        <h1>ブログ</h1>

        {/* 検索フォーム */}
        <Form method='get' className='search-form'>
          <input
            type='search'
            name='search'
            placeholder='記事を検索...'
            defaultValue={searchQuery || ''}
          />
          <button type='submit'>検索</button>
          {searchQuery && (
            <a href='/blog' className='clear-search'>
              検索をクリア
            </a>
          )}
        </Form>
      </header>

      <main>
        {searchQuery && (
          <p className='search-results'>
            「{searchQuery}」で{totalCount}
            件の記事が見つかりました
          </p>
        )}

        {posts.length > 0 ? (
          <>
            <div className='posts-grid'>
              {posts.map((post) => (
                <article
                  key={post.id}
                  className='post-card'
                >
                  <h2>
                    <a href={`/blog/${post.slug}`}>
                      {post.title}
                    </a>
                  </h2>
                  <p className='post-meta'>
                    {new Date(
                      post.publishedAt!
                    ).toLocaleDateString('ja-JP')}
                    | {post.author.name}
                  </p>
                  <p className='post-excerpt'>
                    {post.excerpt}
                  </p>
                  <div className='post-tags'>
                    {post.tags.map((tag) => (
                      <span key={tag.id} className='tag'>
                        {tag.name}
                      </span>
                    ))}
                  </div>
                </article>
              ))}
            </div>

            {/* ページネーション */}
            {pagination.totalPages > 1 && (
              <nav
                className='pagination'
                aria-label='ページネーション'
              >
                {pagination.hasPrev && (
                  <a
                    href={`/blog?${new URLSearchParams({
                      ...Object.fromEntries(searchParams),
                      page: (
                        pagination.currentPage - 1
                      ).toString(),
                    })}`}
                    rel='prev'
                  >
                    前のページ
                  </a>
                )}

                <span className='page-info'>
                  {pagination.currentPage} /{' '}
                  {pagination.totalPages}
                </span>

                {pagination.hasNext && (
                  <a
                    href={`/blog?${new URLSearchParams({
                      ...Object.fromEntries(searchParams),
                      page: (
                        pagination.currentPage + 1
                      ).toString(),
                    })}`}
                    rel='next'
                  >
                    次のページ
                  </a>
                )}
              </nav>
            )}
          </>
        ) : (
          <p className='no-posts'>
            {searchQuery
              ? '検索条件に一致する記事がありません'
              : 'まだ記事がありません'}
          </p>
        )}
      </main>
    </div>
  );
}

記事詳細ページでのコメント機能実装:

typescript// app/routes/blog.$slug.tsx

interface LoaderData {
  post: Post;
  comments: Comment[];
  relatedPosts: Post[];
}

interface ActionData {
  errors?: {
    name?: string;
    email?: string;
    content?: string;
  };
  success?: boolean;
}

export async function loader({
  params,
}: LoaderFunctionArgs): Promise<Response> {
  const post = await getPostBySlug(params.slug!);

  if (!post || !post.publishedAt) {
    throw new Response('記事が見つかりません', {
      status: 404,
    });
  }

  const [comments, relatedPosts] = await Promise.all([
    getPostComments(post.id),
    getRelatedPosts(post.id, 3),
  ]);

  return json<LoaderData>({
    post,
    comments,
    relatedPosts,
  });
}

export async function action({
  request,
  params,
}: ActionFunctionArgs): Promise<Response> {
  const formData = await request.formData();

  const commentData = {
    name: formData.get('name') as string,
    email: formData.get('email') as string,
    content: formData.get('content') as string,
    postSlug: params.slug!,
  };

  // バリデーション
  const errors: ActionData['errors'] = {};

  if (!commentData.name?.trim()) {
    errors.name = '名前は必須です';
  }

  if (!commentData.email?.trim()) {
    errors.email = 'メールアドレスは必須です';
  } else if (
    !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(commentData.email)
  ) {
    errors.email = '有効なメールアドレスを入力してください';
  }

  if (!commentData.content?.trim()) {
    errors.content = 'コメント内容は必須です';
  } else if (commentData.content.length > 1000) {
    errors.content =
      'コメントは1000文字以内で入力してください';
  }

  if (Object.keys(errors).length > 0) {
    return json<ActionData>({ errors }, { status: 400 });
  }

  // コメント投稿処理
  try {
    const post = await getPostBySlug(commentData.postSlug);
    if (!post) {
      throw new Error('投稿が見つかりません');
    }

    await createComment({
      ...commentData,
      postId: post.id,
    });

    return json<ActionData>({ success: true });
  } catch (error) {
    return json<ActionData>(
      {
        errors: {
          content:
            'コメントの投稿に失敗しました。もう一度お試しください。',
        },
      },
      { status: 500 }
    );
  }
}

EC サイトでの商品一覧・詳細ページ

EC サイトでの商品管理システムの実装例です。まずは商品一覧ページから:

typescript// app/routes/shop.$category.tsx

import {
  json,
  type LoaderFunctionArgs,
} from '@remix-run/node';
import {
  useLoaderData,
  useSearchParams,
  Form,
} from '@remix-run/react';

interface Product {
  id: string;
  name: string;
  price: number;
  discountPrice?: number;
  imageUrl: string;
  rating: number;
  reviewCount: number;
  inStock: boolean;
}

interface LoaderData {
  products: Product[];
  category: {
    id: string;
    name: string;
    description: string;
  };
  filters: {
    priceRange: { min: number; max: number };
    brands: Array<{
      id: string;
      name: string;
      count: number;
    }>;
    ratings: Array<{ rating: number; count: number }>;
  };
  pagination: {
    currentPage: number;
    totalPages: number;
    totalCount: number;
  };
  appliedFilters: {
    priceMin?: number;
    priceMax?: number;
    brand?: string;
    rating?: number;
    sortBy?: string;
  };
}

export async function loader({
  request,
  params,
}: LoaderFunctionArgs): Promise<Response> {
  const url = new URL(request.url);
  const categorySlug = params.category!;

  // フィルターパラメータを取得
  const filters = {
    priceMin: url.searchParams.get('price_min')
      ? Number(url.searchParams.get('price_min'))
      : undefined,
    priceMax: url.searchParams.get('price_max')
      ? Number(url.searchParams.get('price_max'))
      : undefined,
    brand: url.searchParams.get('brand') || undefined,
    rating: url.searchParams.get('rating')
      ? Number(url.searchParams.get('rating'))
      : undefined,
    sortBy: url.searchParams.get('sort') || 'popular',
  };

  const page = Math.max(
    1,
    parseInt(url.searchParams.get('page') || '1')
  );
  const limit = 24;

  // 商品カテゴリとフィルター情報を並行取得
  const [category, products, totalCount, filterOptions] =
    await Promise.all([
      getCategoryBySlug(categorySlug),
      getProductsByCategory(categorySlug, {
        ...filters,
        page,
        limit,
      }),
      getProductCountByCategory(categorySlug, filters),
      getCategoryFilters(categorySlug),
    ]);

  if (!category) {
    throw new Response('カテゴリが見つかりません', {
      status: 404,
    });
  }

  return json<LoaderData>({
    products,
    category,
    filters: filterOptions,
    pagination: {
      currentPage: page,
      totalPages: Math.ceil(totalCount / limit),
      totalCount,
    },
    appliedFilters: filters,
  });
}

フィルター機能付きの商品一覧コンポーネント:

typescript// 同じファイル内のコンポーネント部分

export default function CategoryProducts() {
  const {
    products,
    category,
    filters,
    pagination,
    appliedFilters,
  } = useLoaderData<typeof loader>();

  const [searchParams] = useSearchParams();

  return (
    <div className='category-page'>
      <header className='category-header'>
        <h1>{category.name}</h1>
        <p>{category.description}</p>
        <p className='results-count'>
          {pagination.totalCount}件の商品が見つかりました
        </p>
      </header>

      <div className='shop-layout'>
        {/* フィルターサイドバー */}
        <aside className='filters-sidebar'>
          <Form method='get'>
            {/* 価格フィルター */}
            <div className='filter-group'>
              <h3>価格</h3>
              <div className='price-range'>
                <input
                  type='number'
                  name='price_min'
                  placeholder='最低価格'
                  defaultValue={
                    appliedFilters.priceMin || ''
                  }
                  min={filters.priceRange.min}
                  max={filters.priceRange.max}
                />
                <span></span>
                <input
                  type='number'
                  name='price_max'
                  placeholder='最高価格'
                  defaultValue={
                    appliedFilters.priceMax || ''
                  }
                  min={filters.priceRange.min}
                  max={filters.priceRange.max}
                />
              </div>
            </div>

            {/* ブランドフィルター */}
            <div className='filter-group'>
              <h3>ブランド</h3>
              {filters.brands.map((brand) => (
                <label
                  key={brand.id}
                  className='checkbox-label'
                >
                  <input
                    type='radio'
                    name='brand'
                    value={brand.id}
                    defaultChecked={
                      appliedFilters.brand === brand.id
                    }
                  />
                  {brand.name} ({brand.count})
                </label>
              ))}
            </div>

            {/* 評価フィルター */}
            <div className='filter-group'>
              <h3>評価</h3>
              {filters.ratings.map((ratingFilter) => (
                <label
                  key={ratingFilter.rating}
                  className='checkbox-label'
                >
                  <input
                    type='radio'
                    name='rating'
                    value={ratingFilter.rating}
                    defaultChecked={
                      appliedFilters.rating ===
                      ratingFilter.rating
                    }
                  />
                  ★{ratingFilter.rating}以上 ({
                    ratingFilter.count
                  })
                </label>
              ))}
            </div>

            <button type='submit' className='apply-filters'>
              フィルターを適用
            </button>
          </Form>
        </aside>

        <main className='products-main'>
          {/* ソート機能 */}
          <div className='sort-bar'>
            <Form method='get'>
              {/* 現在のフィルター値を保持 */}
              {Object.entries(appliedFilters).map(
                ([key, value]) =>
                  key !== 'sortBy' &&
                  value && (
                    <input
                      key={key}
                      type='hidden'
                      name={key}
                      value={value}
                    />
                  )
              )}

              <select
                name='sort'
                defaultValue={appliedFilters.sortBy}
                onChange={(e) =>
                  e.currentTarget.form?.submit()
                }
              >
                <option value='popular'>人気順</option>
                <option value='price_asc'>
                  価格の安い順
                </option>
                <option value='price_desc'>
                  価格の高い順
                </option>
                <option value='rating'>評価順</option>
                <option value='newest'>新着順</option>
              </select>
            </Form>
          </div>

          {/* 商品グリッド */}
          <div className='products-grid'>
            {products.map((product) => (
              <div
                key={product.id}
                className='product-card'
              >
                <a href={`/shop/products/${product.id}`}>
                  <img
                    src={product.imageUrl}
                    alt={product.name}
                  />
                  <h3>{product.name}</h3>
                  <div className='price'>
                    {product.discountPrice ? (
                      <>
                        <span className='sale-price'>
                          ¥
                          {product.discountPrice.toLocaleString()}
                        </span>
                        <span className='original-price'>
                          ¥{product.price.toLocaleString()}
                        </span>
                      </>
                    ) : (
                      <span>
                        ¥{product.price.toLocaleString()}
                      </span>
                    )}
                  </div>
                  <div className='rating'>
                    ★{product.rating} ({product.reviewCount}
                    件)
                  </div>
                  {!product.inStock && (
                    <div className='out-of-stock'>
                      在庫切れ
                    </div>
                  )}
                </a>
              </div>
            ))}
          </div>

          {/* ページネーション */}
          {pagination.totalPages > 1 && (
            <nav className='pagination'>
              {/* ページネーションの実装は先ほどと同様 */}
            </nav>
          )}
        </main>
      </div>
    </div>
  );
}

ユーザー管理システムでの CRUD 操作

管理画面でのユーザー管理機能の実装例です:

typescript// app/routes/admin.users._index.tsx

interface User {
  id: string;
  name: string;
  email: string;
  role: 'admin' | 'user' | 'moderator';
  status: 'active' | 'inactive' | 'suspended';
  createdAt: string;
  lastLoginAt: string | null;
}

interface LoaderData {
  users: User[];
  pagination: {
    currentPage: number;
    totalPages: number;
    totalCount: number;
  };
  filters: {
    role?: string;
    status?: string;
    search?: string;
  };
}

export async function loader({
  request,
}: LoaderFunctionArgs): Promise<Response> {
  // 管理者権限チェック
  const currentUser = await getCurrentUser(request);
  if (!currentUser || currentUser.role !== 'admin') {
    throw new Response('アクセス権限がありません', {
      status: 403,
    });
  }

  const url = new URL(request.url);
  const page = Math.max(
    1,
    parseInt(url.searchParams.get('page') || '1')
  );
  const limit = 20;

  const filters = {
    role: url.searchParams.get('role') || undefined,
    status: url.searchParams.get('status') || undefined,
    search: url.searchParams.get('search') || undefined,
  };

  const [users, totalCount] = await Promise.all([
    getUsers({ ...filters, page, limit }),
    getUserCount(filters),
  ]);

  return json<LoaderData>({
    users,
    pagination: {
      currentPage: page,
      totalPages: Math.ceil(totalCount / limit),
      totalCount,
    },
    filters,
  });
}

// ユーザー操作用Action
export async function action({
  request,
}: ActionFunctionArgs): Promise<Response> {
  const currentUser = await getCurrentUser(request);
  if (!currentUser || currentUser.role !== 'admin') {
    throw new Response('アクセス権限がありません', {
      status: 403,
    });
  }

  const formData = await request.formData();
  const intent = formData.get('intent') as string;
  const userId = formData.get('userId') as string;

  switch (intent) {
    case 'suspend': {
      await updateUserStatus(userId, 'suspended');
      return json({
        success: true,
        message: 'ユーザーを停止しました',
      });
    }

    case 'activate': {
      await updateUserStatus(userId, 'active');
      return json({
        success: true,
        message: 'ユーザーを有効化しました',
      });
    }

    case 'delete': {
      await deleteUser(userId);
      return json({
        success: true,
        message: 'ユーザーを削除しました',
      });
    }

    case 'changeRole': {
      const newRole = formData.get('role') as User['role'];
      await updateUserRole(userId, newRole);
      return json({
        success: true,
        message: '権限を変更しました',
      });
    }

    default:
      throw new Response('無効な操作です', { status: 400 });
  }
}

ユーザー管理画面のコンポーネント:

typescript// 同じファイル内のコンポーネント部分

export default function UsersAdmin() {
  const { users, pagination, filters } =
    useLoaderData<typeof loader>();
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();

  const isProcessing = navigation.state === 'submitting';

  return (
    <div className='admin-users'>
      <header className='page-header'>
        <h1>ユーザー管理</h1>
        <a href='/admin/users/new' className='btn-primary'>
          新規ユーザー作成
        </a>
      </header>

      {actionData?.success && (
        <div className='alert alert-success'>
          {actionData.message}
        </div>
      )}

      {/* フィルター */}
      <div className='filters'>
        <Form method='get'>
          <input
            type='search'
            name='search'
            placeholder='名前またはメールアドレスで検索'
            defaultValue={filters.search || ''}
          />

          <select
            name='role'
            defaultValue={filters.role || ''}
          >
            <option value=''>すべての権限</option>
            <option value='admin'>管理者</option>
            <option value='moderator'>モデレーター</option>
            <option value='user'>一般ユーザー</option>
          </select>

          <select
            name='status'
            defaultValue={filters.status || ''}
          >
            <option value=''>すべてのステータス</option>
            <option value='active'>有効</option>
            <option value='inactive'>無効</option>
            <option value='suspended'>停止中</option>
          </select>

          <button type='submit'>検索</button>
        </Form>
      </div>

      {/* ユーザーテーブル */}
      <div className='users-table'>
        <table>
          <thead>
            <tr>
              <th>名前</th>
              <th>メールアドレス</th>
              <th>権限</th>
              <th>ステータス</th>
              <th>登録日</th>
              <th>最終ログイン</th>
              <th>操作</th>
            </tr>
          </thead>
          <tbody>
            {users.map((user) => (
              <tr key={user.id}>
                <td>{user.name}</td>
                <td>{user.email}</td>
                <td>
                  <Form
                    method='post'
                    style={{ display: 'inline' }}
                  >
                    <input
                      type='hidden'
                      name='intent'
                      value='changeRole'
                    />
                    <input
                      type='hidden'
                      name='userId'
                      value={user.id}
                    />
                    <select
                      name='role'
                      defaultValue={user.role}
                      onChange={(e) =>
                        e.currentTarget.form?.submit()
                      }
                      disabled={isProcessing}
                    >
                      <option value='user'>
                        一般ユーザー
                      </option>
                      <option value='moderator'>
                        モデレーター
                      </option>
                      <option value='admin'>管理者</option>
                    </select>
                  </Form>
                </td>
                <td>
                  <span className={`status ${user.status}`}>
                    {user.status === 'active'
                      ? '有効'
                      : user.status === 'inactive'
                      ? '無効'
                      : '停止中'}
                  </span>
                </td>
                <td>
                  {new Date(
                    user.createdAt
                  ).toLocaleDateString('ja-JP')}
                </td>
                <td>
                  {user.lastLoginAt
                    ? new Date(
                        user.lastLoginAt
                      ).toLocaleDateString('ja-JP')
                    : 'ログインなし'}
                </td>
                <td className='actions'>
                  <a
                    href={`/admin/users/${user.id}`}
                    className='btn-secondary'
                  >
                    詳細
                  </a>

                  {user.status === 'active' ? (
                    <Form
                      method='post'
                      style={{ display: 'inline' }}
                    >
                      <input
                        type='hidden'
                        name='intent'
                        value='suspend'
                      />
                      <input
                        type='hidden'
                        name='userId'
                        value={user.id}
                      />
                      <button
                        type='submit'
                        className='btn-warning'
                        disabled={isProcessing}
                        onClick={(e) => {
                          if (
                            !confirm(
                              'このユーザーを停止しますか?'
                            )
                          ) {
                            e.preventDefault();
                          }
                        }}
                      >
                        停止
                      </button>
                    </Form>
                  ) : (
                    <Form
                      method='post'
                      style={{ display: 'inline' }}
                    >
                      <input
                        type='hidden'
                        name='intent'
                        value='activate'
                      />
                      <input
                        type='hidden'
                        name='userId'
                        value={user.id}
                      />
                      <button
                        type='submit'
                        className='btn-success'
                        disabled={isProcessing}
                      >
                        有効化
                      </button>
                    </Form>
                  )}

                  <Form
                    method='post'
                    style={{ display: 'inline' }}
                  >
                    <input
                      type='hidden'
                      name='intent'
                      value='delete'
                    />
                    <input
                      type='hidden'
                      name='userId'
                      value={user.id}
                    />
                    <button
                      type='submit'
                      className='btn-danger'
                      disabled={isProcessing}
                      onClick={(e) => {
                        if (
                          !confirm(
                            'このユーザーを削除しますか?この操作は元に戻せません。'
                          )
                        ) {
                          e.preventDefault();
                        }
                      }}
                    >
                      削除
                    </button>
                  </Form>
                </td>
              </tr>
            ))}
          </tbody>
        </table>
      </div>

      {/* ページネーション */}
      {pagination.totalPages > 1 && (
        <nav className='pagination'>
          {/* 前のページへのリンクなど */}
        </nav>
      )}
    </div>
  );
}

まとめ

Remix の Loader/Action を使いこなすことで、従来の React アプリケーション開発では実現が困難だった高度な機能を、シンプルで保守性の高いコードで実装できるようになります。本記事で学んだ内容を整理すると、以下のポイントが重要です。

基本的な理解では、Loader と Action の役割分担を明確にしました。Loader はサーバーサイドでのデータ取得を担当し、Action はフォーム処理やデータ更新を処理します。この区別を理解することで、適切な場面で適切な機能を選択できるようになりますね。

パフォーマンス最適化の観点では、キャッシュ戦略の実装や並行データ取得、適切なエラーハンドリングの実装方法をご紹介しました。これらのテクニックにより、ユーザー体験の向上と開発効率の向上を両立できます。

TypeScript 連携により、型安全なアプリケーション開発が可能になります。Loader や Action の戻り値の型を適切に定義することで、実行時エラーを防ぎ、開発時の DX を大幅に改善できるでしょう。

実践的な活用場面として、ブログシステム、EC サイト、管理画面での具体的な実装例をご紹介しました。これらの例を参考に、皆さんのプロジェクトに応用していただければと思います。

今後 Remix を使った開発を進める際は、まず小さな機能から始めて段階的に複雑な機能を実装していくことをお勧めします。本記事で紹介した基本パターンを組み合わせることで、ほとんどの Web アプリケーション要件に対応できるはずです。

継続的な学習と実践を通じて、Remix の Loader/Action を使った効率的な開発スキルを身につけていってくださいね。

関連リンク