T-CREATOR

Remix の仕組みを図解で理解:ルーティング/データロード/アクションの全体像

Remix の仕組みを図解で理解:ルーティング/データロード/アクションの全体像

Remix は、モダンな Web アプリケーション開発を革新するフルスタックフレームワークとして注目を集めていますね。 従来の React 開発では、ルーティング、データフェッチ、状態管理を別々のライブラリで組み合わせる必要がありましたが、Remix はこれらを統合的に提供します。

本記事では、Remix の核となる「ルーティング」「データロード」「アクション」の 3 つの仕組みを、図解を交えながら詳しく解説していきます。 初心者の方でも理解できるよう、段階的に説明していきますので、ぜひ最後までお付き合いください。

背景

Remix が生まれた理由

React エコシステムにおいて、長年にわたり開発者は複数のライブラリを組み合わせてアプリケーションを構築してきました。 ルーティングには React Router、データフェッチには axios や fetch、状態管理には Redux や Context API など、それぞれ異なるツールを使用する必要がありました。

この状況には以下のような課題がありましたね。

  • ライブラリ間の統合に手間がかかる
  • データフェッチとルーティングの連携が複雑になる
  • サーバーサイドレンダリング(SSR)の実装が困難
  • パフォーマンス最適化に高度な知識が必要

Remix は、これらの課題を解決するために、React Router の開発者たちによって設計されました。

Remix のアーキテクチャ哲学

Remix は「Web 標準」を重視したフレームワークです。 ブラウザの標準機能である HTTP メソッド、Form、URL などを活用することで、シンプルで堅牢なアプリケーション開発を実現しています。

以下の図は、Remix のアーキテクチャ全体像を示しています。

mermaidflowchart TB
  browser["ブラウザ<br/>(ユーザー)"]

  subgraph remix["Remix アプリケーション"]
    router["ルーター<br/>(ファイルベース)"]
    loader["loader関数<br/>(データ取得)"]
    action["action関数<br/>(データ変更)"]
    component["Reactコンポーネント<br/>(UI表示)"]
  end

  subgraph backend["バックエンド"]
    api["API"]
    db[("データベース")]
  end

  browser -->|"GET リクエスト"| router
  browser -->|"POST リクエスト<br/>(Form送信)"| router
  router -->|"URL解析"| loader
  router -->|"Form処理"| action
  loader -->|"データ取得"| api
  action -->|"データ更新"| api
  api <-->|"CRUD操作"| db
  loader -->|"データ提供"| component
  action -->|"処理結果"| component
  component -->|"HTML/JSON"| browser

この図から、Remix がリクエストを受け取ってからレスポンスを返すまでの基本的な流れが理解できますね。

図で理解できる要点:

  • ブラウザからのリクエストは必ずルーターを経由する
  • GET リクエストは loader 関数で処理され、POST リクエストは action 関数で処理される
  • 各関数はバックエンド API と連携してデータベースとやり取りする

課題

従来の React 開発における課題

Remix が解決しようとしている具体的な課題を見ていきましょう。

データフェッチのタイミング問題

従来の React 開発では、コンポーネントがマウントされた後にuseEffectでデータをフェッチするのが一般的でした。 これには以下のような問題がありましたね。

#課題影響
1初回レンダリング時にローディング状態が表示されるユーザー体験の低下
2データ取得が遅延する(ウォーターフォール問題)パフォーマンスの悪化
3SEO 対策が困難検索エンジンでの評価低下
4エラーハンドリングが複雑化開発コストの増加

状態管理の複雑さ

クライアントサイドで状態管理を行う場合、以下のような課題に直面します。

typescript// 従来の React でのデータフェッチ例
import { useState, useEffect } from 'react';

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    // データフェッチ処理
    fetchUser(userId)
      .then((data) => setUser(data))
      .catch((err) => setError(err))
      .finally(() => setLoading(false));
  }, [userId]);

  // ローディング、エラー、成功状態の管理が必要
  if (loading) return <div>読み込み中...</div>;
  if (error) return <div>エラーが発生しました</div>;
  if (!user) return <div>ユーザーが見つかりません</div>;

  return <div>{user.name}</div>;
}

このコードでは、loadingerroruserという 3 つの状態を手動で管理する必要があります。 アプリケーションが大きくなるにつれて、この状態管理は複雑になっていきますね。

フォーム処理の煩雑さ

従来の React 開発では、フォームの送信処理も複雑でした。

typescript// 従来の React でのフォーム処理例
function CreatePost() {
  const [title, setTitle] = useState('');
  const [content, setContent] = useState('');
  const [submitting, setSubmitting] = useState(false);

  const handleSubmit = async (e) => {
    e.preventDefault();
    setSubmitting(true);

    try {
      // API呼び出し
      await fetch('/api/posts', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ title, content }),
      });

      // 成功後の処理
      setTitle('');
      setContent('');
    } catch (error) {
      console.error(error);
    } finally {
      setSubmitting(false);
    }
  };

  return (
    <form onSubmit={handleSubmit}>
      {/* フォーム要素 */}
    </form>
  );
}

各入力フィールドの状態管理、送信中の状態管理、エラーハンドリングなど、多くのボイラープレートコードが必要になります。

以下の図は、従来の React 開発におけるデータフローの課題を示しています。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Browser as ブラウザ
  participant React as React<br/>コンポーネント
  participant API as API

  User->>Browser: ページアクセス
  Browser->>React: 初回レンダリング
  React->>Browser: ローディング表示
  Note over React,API: コンポーネントマウント後に<br/>データフェッチ開始
  React->>API: データリクエスト
  API-->>React: データレスポンス
  React->>Browser: データ表示
  Note over User,Browser: 初回表示まで時間がかかる

この図から、従来の方法では初回レンダリングとデータフェッチが分離されているため、ユーザーが実際のコンテンツを見るまでに時間がかかることがわかりますね。

図で理解できる要点:

  • コンポーネントのマウント後にデータフェッチが開始される
  • ローディング状態の表示期間が長くなる
  • データ取得の遅延によりユーザー体験が低下する

解決策

Remix の統合的アプローチ

Remix は、ルーティング、データロード、アクションを統合することで、これらの課題を解決します。 それぞれの仕組みを詳しく見ていきましょう。

ファイルベースルーティング

Remix では、ファイルシステムがそのままルーティング構造になります。 これにより、直感的でメンテナンスしやすいルート管理が可能になりますね。

ルーティングの基本構造

bashapp/
  routes/
    _index.tsx          → /
    about.tsx           → /about
    blog._index.tsx     → /blog
    blog.$slug.tsx      → /blog/:slug
    users.$userId.tsx   → /users/:userId

このファイル構造から、URL パスが自動的に生成されます。 $で始まる部分は動的パラメータとして扱われます。

ネストされたルーティング

Remix の強力な機能の 1 つが、ネストされたルーティングです。

bashapp/
  routes/
    dashboard.tsx              → /dashboard (親レイアウト)
    dashboard._index.tsx       → /dashboard (トップページ)
    dashboard.settings.tsx     → /dashboard/settings
    dashboard.profile.tsx      → /dashboard/profile

以下の図は、ネストされたルーティングの構造を示しています。

mermaidflowchart TD
  root["ルート<br/>(root.tsx)"]
  dashboard["ダッシュボード<br/>(dashboard.tsx)"]
  index["インデックス<br/>(dashboard._index.tsx)"]
  settings["設定<br/>(dashboard.settings.tsx)"]
  profile["プロフィール<br/>(dashboard.profile.tsx)"]

  root --> dashboard
  dashboard --> index
  dashboard --> settings
  dashboard --> profile

  style root fill:#e1f5ff
  style dashboard fill:#fff4e1
  style index fill:#f0f0f0
  style settings fill:#f0f0f0
  style profile fill:#f0f0f0

親ルート(dashboard.tsx)は子ルート全体で共有されるレイアウトとして機能します。

図で理解できる要点:

  • ルートは階層構造を持つ
  • 親ルートは子ルート全体のレイアウトを提供する
  • 各子ルートは独立したコンテンツを持つ

ルートコンポーネントの実装

typescript// app/routes/dashboard.tsx
import { Outlet } from '@remix-run/react';

// 親ルート: ダッシュボード全体のレイアウト
export default function Dashboard() {
  return (
    <div className='dashboard-layout'>
      <header>
        <h1>ダッシュボード</h1>
        <nav>{/* ナビゲーションメニュー */}</nav>
      </header>

      {/* 子ルートがここに表示される */}
      <Outlet />
    </div>
  );
}

Outletコンポーネントは、子ルートのコンテンツが表示される場所を指定します。 この仕組みにより、レイアウトの再利用が簡単になりますね。

loader 関数によるデータロード

Remix のloader関数は、ルートコンポーネントがレンダリングされる前にサーバーサイドでデータを取得します。

loader 関数の基本

typescript// app/routes/users.$userId.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';

// サーバーサイドで実行されるデータ取得関数
export async function loader({
  params,
}: LoaderFunctionArgs) {
  const userId = params.userId;

  // データベースまたはAPIからユーザー情報を取得
  const user = await db.user.findUnique({
    where: { id: userId },
  });

  if (!user) {
    // ユーザーが見つからない場合は404エラー
    throw new Response('Not Found', { status: 404 });
  }

  // JSON形式でデータを返す
  return json({ user });
}

このloader関数は、ルートにアクセスされたときに自動的に実行されます。 パラメータやクエリ文字列はparamsrequestオブジェクトから取得できますね。

loader データの使用

typescript// 同じファイル内のコンポーネント
export default function UserProfile() {
  // loaderから返されたデータを取得
  const { user } = useLoaderData<typeof loader>();

  return (
    <div className='user-profile'>
      <h2>{user.name}</h2>
      <p>{user.email}</p>
      <p>登録日: {user.createdAt}</p>
    </div>
  );
}

useLoaderDataフックを使用することで、loader関数から返されたデータに型安全にアクセスできます。 従来のuseStateuseEffectによるデータ管理が不要になりますね。

複数の loader の並列実行

ネストされたルートでは、各ルートのloaderが並列に実行されます。

mermaidsequenceDiagram
  participant Browser as ブラウザ
  participant Remix as Remix<br/>サーバー
  participant Loader1 as loader<br/>(親ルート)
  participant Loader2 as loader<br/>(子ルート)
  participant DB as データベース

  Browser->>Remix: /dashboard/profile<br/>アクセス

  par 並列実行
    Remix->>Loader1: 親ルートのloader実行
    Loader1->>DB: ダッシュボード情報取得
    DB-->>Loader1: データ返却

    Remix->>Loader2: 子ルートのloader実行
    Loader2->>DB: プロフィール情報取得
    DB-->>Loader2: データ返却
  end

  Loader1-->>Remix: ダッシュボードデータ
  Loader2-->>Remix: プロフィールデータ
  Remix->>Browser: 完全なHTMLレスポンス

この並列実行により、ウォーターフォール問題が解消され、パフォーマンスが大幅に向上します。

図で理解できる要点:

  • 親ルートと子ルートの loader は並列に実行される
  • データ取得の待ち時間が最小化される
  • 完全なデータを含む HTML がレンダリングされる

エラーハンドリング

typescript// app/routes/users.$userId.tsx
import {
  isRouteErrorResponse,
  useRouteError,
} from '@remix-run/react';

// エラーバウンダリーコンポーネント
export function ErrorBoundary() {
  const error = useRouteError();

  // HTTPエラーレスポンスの場合
  if (isRouteErrorResponse(error)) {
    return (
      <div className='error-container'>
        <h1>
          {error.status} {error.statusText}
        </h1>
        <p>{error.data}</p>
      </div>
    );
  }

  // その他のエラーの場合
  return (
    <div className='error-container'>
      <h1>予期しないエラーが発生しました</h1>
      <p>申し訳ございませんが、問題が発生しました。</p>
    </div>
  );
}

ErrorBoundaryをエクスポートすることで、ルートレベルでエラーハンドリングを一元管理できます。

action 関数によるデータ変更

action関数は、フォーム送信やデータ変更を処理するためのサーバーサイド関数です。

action 関数の基本

typescript// app/routes/posts.new.tsx
import { json, redirect } from '@remix-run/node';
import type { ActionFunctionArgs } from '@remix-run/node';

// POSTリクエストを処理する関数
export async function action({
  request,
}: ActionFunctionArgs) {
  // フォームデータを取得
  const formData = await request.formData();
  const title = formData.get('title');
  const content = formData.get('content');

  // バリデーション
  const errors: { title?: string; content?: string } = {};

  if (!title || typeof title !== 'string') {
    errors.title = 'タイトルは必須です';
  }

  if (!content || typeof content !== 'string') {
    errors.content = '内容は必須です';
  }

  // エラーがある場合は戻す
  if (Object.keys(errors).length > 0) {
    return json({ errors }, { status: 400 });
  }

  // データベースに保存
  const post = await db.post.create({
    data: { title, content },
  });

  // 作成した投稿ページにリダイレクト
  return redirect(`/posts/${post.id}`);
}

action関数は、POSTPUTPATCHDELETEなどの HTTP メソッドで呼び出されます。 フォームから送信されたデータはrequest.formData()で取得できますね。

Form コンポーネントの使用

typescript// 同じファイル内のコンポーネント
import { Form, useActionData } from '@remix-run/react';

export default function NewPost() {
  // actionから返されたデータ(エラーなど)を取得
  const actionData = useActionData<typeof action>();

  return (
    <div className='new-post'>
      <h1>新しい投稿を作成</h1>

      {/* Remix の Form コンポーネント */}
      <Form method='post'>
        <div className='form-group'>
          <label htmlFor='title'>タイトル</label>
          <input
            type='text'
            id='title'
            name='title'
            required
          />
          {/* エラーメッセージの表示 */}
          {actionData?.errors?.title && (
            <p className='error'>
              {actionData.errors.title}
            </p>
          )}
        </div>

        <div className='form-group'>
          <label htmlFor='content'>内容</label>
          <textarea id='content' name='content' required />
          {actionData?.errors?.content && (
            <p className='error'>
              {actionData.errors.content}
            </p>
          )}
        </div>

        <button type='submit'>投稿する</button>
      </Form>
    </div>
  );
}

Remix のFormコンポーネントを使用すると、JavaScript が無効でもフォームが動作します。 これはプログレッシブエンハンスメントの原則に基づいていますね。

action 実行のフロー

以下の図は、フォーム送信時の action 実行フローを示しています。

mermaidstateDiagram-v2
  [*] --> Idle: フォーム表示
  Idle --> Submitting: フォーム送信
  Submitting --> Processing: action関数実行

  Processing --> Validation: データ検証
  Validation --> Error: 検証失敗
  Validation --> Saving: 検証成功

  Error --> Idle: エラー表示
  Saving --> Success: データ保存完了
  Success --> Redirect: リダイレクト
  Redirect --> [*]

  note right of Processing
    サーバーサイドで
    セキュアに処理
  end note

  note right of Error
    バリデーション
    エラーを返却
  end note

この状態遷移図から、action の実行プロセスが段階的に理解できますね。

図で理解できる要点:

  • フォーム送信は常にサーバーサイドで処理される
  • バリデーションエラーは適切にハンドリングされる
  • 成功時はリダイレクトにより新しいページに遷移する

楽観的 UI 更新

Remix では、useNavigationuseFetcherを使用して楽観的 UI 更新も実装できます。

typescriptimport { useFetcher } from '@remix-run/react';

export default function LikeButton({
  postId,
  initialLikes,
}) {
  const fetcher = useFetcher();

  // 楽観的に更新された値を計算
  const likes = fetcher.formData
    ? initialLikes + 1
    : initialLikes;

  // 送信中かどうかを判定
  const isLiking = fetcher.state === 'submitting';

  return (
    <fetcher.Form
      method='post'
      action={`/posts/${postId}/like`}
    >
      <button type='submit' disabled={isLiking}>
        ♥ {likes}
      </button>
    </fetcher.Form>
  );
}

useFetcherを使用すると、ページ遷移なしで action を実行できます。 ボタンをクリックした瞬間に楽観的に UI が更新され、ユーザー体験が向上しますね。

具体例

実際のアプリケーション開発で Remix の仕組みを活用する具体例を見ていきましょう。

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

ブログアプリケーションを例に、ルーティング、loader、action の連携を説明します。

アプリケーション構造

bashapp/
  routes/
    _index.tsx               → / (トップページ)
    blog._index.tsx          → /blog (記事一覧)
    blog.$slug.tsx           → /blog/:slug (記事詳細)
    blog.new.tsx             → /blog/new (新規作成)
    blog.$slug.edit.tsx      → /blog/:slug/edit (編集)

以下の図は、ブログアプリケーション全体のデータフローを示しています。

mermaidflowchart TD
  user["ユーザー"]

  subgraph routes["ルート構成"]
    index["トップページ<br/>(_index.tsx)"]
    list["記事一覧<br/>(blog._index.tsx)"]
    detail["記事詳細<br/>(blog.$slug.tsx)"]
    new_post["新規作成<br/>(blog.new.tsx)"]
    edit["編集<br/>(blog.$slug.edit.tsx)"]
  end

  subgraph data_ops["データ操作"]
    load_list["loader<br/>(記事リスト取得)"]
    load_detail["loader<br/>(記事詳細取得)"]
    create["action<br/>(記事作成)"]
    update["action<br/>(記事更新)"]
  end

  db[("データベース")]

  user -->|"GET /blog"| list
  user -->|"GET /blog/hello"| detail
  user -->|"POST /blog/new"| new_post
  user -->|"POST /blog/hello/edit"| edit

  list --> load_list
  detail --> load_detail
  new_post --> create
  edit --> update

  load_list <--> db
  load_detail <--> db
  create --> db
  update --> db

  create -->|"redirect"| detail
  update -->|"redirect"| detail

この図から、各ルートが適切な loader/action と連携していることがわかりますね。

図で理解できる要点:

  • GET リクエストは loader でデータを取得する
  • POST リクエストは action でデータを変更する
  • 変更後は該当ページにリダイレクトする

記事一覧ページの実装

typescript// app/routes/blog._index.tsx
import { json } from '@remix-run/node';
import { Link, useLoaderData } from '@remix-run/react';

// 記事リストを取得するloader
export async function loader() {
  const posts = await db.post.findMany({
    select: {
      id: true,
      slug: true,
      title: true,
      excerpt: true,
      createdAt: true,
    },
    orderBy: { createdAt: 'desc' },
  });

  return json({ posts });
}

ここでは、データベースから記事リストを取得し、JSON 形式で返しています。 必要な項目のみをselectで指定することで、パフォーマンスを最適化していますね。

typescript// コンポーネント部分
export default function BlogIndex() {
  const { posts } = useLoaderData<typeof loader>();

  return (
    <div className='blog-index'>
      <header>
        <h1>ブログ記事一覧</h1>
        <Link to='/blog/new' className='button'>
          新規記事を作成
        </Link>
      </header>

      <div className='posts-grid'>
        {posts.map((post) => (
          <article key={post.id} className='post-card'>
            <h2>
              <Link to={`/blog/${post.slug}`}>
                {post.title}
              </Link>
            </h2>
            <p className='excerpt'>{post.excerpt}</p>
            <time dateTime={post.createdAt}>
              {new Date(post.createdAt).toLocaleDateString(
                'ja-JP'
              )}
            </time>
          </article>
        ))}
      </div>
    </div>
  );
}

loader から取得したデータを使って、記事カードを表示しています。 Linkコンポーネントによる遷移は、JavaScript によるクライアントサイドナビゲーションとして動作しますね。

記事詳細ページの実装

typescript// app/routes/blog.$slug.tsx
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import type { LoaderFunctionArgs } from '@remix-run/node';

// 記事詳細を取得するloader
export async function loader({
  params,
}: LoaderFunctionArgs) {
  const { slug } = params;

  const post = await db.post.findUnique({
    where: { slug },
    include: {
      author: true,
      tags: true,
    },
  });

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

  return json({ post });
}

URL パラメータ(slug)を使用して、特定の記事を取得しています。 記事が存在しない場合は 404 エラーをスローすることで、適切なエラーページが表示されますね。

typescript// コンポーネント部分
export default function BlogPost() {
  const { post } = useLoaderData<typeof loader>();

  return (
    <article className='blog-post'>
      <header>
        <h1>{post.title}</h1>
        <div className='post-meta'>
          <span className='author'>{post.author.name}</span>
          <time dateTime={post.createdAt}>
            {new Date(post.createdAt).toLocaleDateString(
              'ja-JP'
            )}
          </time>
        </div>
        <div className='tags'>
          {post.tags.map((tag) => (
            <span key={tag.id} className='tag'>
              {tag.name}
            </span>
          ))}
        </div>
      </header>

      <div
        className='post-content'
        dangerouslySetInnerHTML={{ __html: post.content }}
      />

      <footer>
        <Link
          to={`/blog/${post.slug}/edit`}
          className='button'
        >
          編集する
        </Link>
      </footer>
    </article>
  );
}

記事の本文、著者情報、タグなど、関連データも含めて表示しています。

記事作成ページの実装

typescript// app/routes/blog.new.tsx
import { json, redirect } from '@remix-run/node';
import {
  Form,
  useActionData,
  useNavigation,
} from '@remix-run/react';
import type { ActionFunctionArgs } from '@remix-run/node';

// バリデーション関数
function validatePost(title: string, content: string) {
  const errors: { title?: string; content?: string } = {};

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

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

  return Object.keys(errors).length > 0 ? errors : null;
}

まず、バリデーション関数を定義しています。 ビジネスロジックを関数として分離することで、テストやメンテナンスが容易になりますね。

typescript// 記事を作成するaction
export async function action({
  request,
}: ActionFunctionArgs) {
  const formData = await request.formData();
  const title = formData.get('title') as string;
  const content = formData.get('content') as string;

  // バリデーション
  const errors = validatePost(title, content);
  if (errors) {
    return json({ errors }, { status: 400 });
  }

  // スラッグの生成
  const slug = title
    .toLowerCase()
    .replace(/[^a-z0-9]+/g, '-')
    .replace(/^-|-$/g, '');

  // データベースに保存
  const post = await db.post.create({
    data: {
      title,
      content,
      slug,
      excerpt: content.substring(0, 150),
    },
  });

  // 作成した記事ページにリダイレクト
  return redirect(`/blog/${post.slug}`);
}

action では、フォームデータの検証、スラッグの生成、データベースへの保存を行っています。 すべての処理が完了したら、作成した記事の詳細ページにリダイレクトしますね。

typescript// コンポーネント部分
export default function NewPost() {
  const actionData = useActionData<typeof action>();
  const navigation = useNavigation();

  // フォーム送信中かどうかを判定
  const isSubmitting = navigation.state === 'submitting';

  return (
    <div className='new-post'>
      <h1>新しい記事を作成</h1>

      <Form method='post' className='post-form'>
        <div className='form-group'>
          <label htmlFor='title'>タイトル</label>
          <input
            type='text'
            id='title'
            name='title'
            placeholder='記事のタイトルを入力'
            aria-invalid={
              actionData?.errors?.title ? true : undefined
            }
            disabled={isSubmitting}
          />
          {actionData?.errors?.title && (
            <p className='error' role='alert'>
              {actionData.errors.title}
            </p>
          )}
        </div>

        <div className='form-group'>
          <label htmlFor='content'>本文</label>
          <textarea
            id='content'
            name='content'
            rows={15}
            placeholder='記事の本文を入力'
            aria-invalid={
              actionData?.errors?.content ? true : undefined
            }
            disabled={isSubmitting}
          />
          {actionData?.errors?.content && (
            <p className='error' role='alert'>
              {actionData.errors.content}
            </p>
          )}
        </div>

        <div className='form-actions'>
          <button
            type='submit'
            className='button primary'
            disabled={isSubmitting}
          >
            {isSubmitting ? '投稿中...' : '投稿する'}
          </button>
          <Link to='/blog' className='button secondary'>
            キャンセル
          </Link>
        </div>
      </Form>
    </div>
  );
}

useNavigationフックを使用して、フォームの送信状態を取得しています。 送信中はボタンを disable にし、適切なフィードバックを提供することで、ユーザー体験が向上しますね。

データの再検証(Revalidation)

Remix の優れた機能の 1 つが、自動的なデータの再検証です。

再検証のタイミング

以下の表は、Remix が自動的にデータを再検証するタイミングをまとめています。

#タイミング説明再検証対象
1action 実行後フォーム送信などの action 完了時現在のページの全 loader
2URL パラメータ変更​/​blog​/​post-1 から ​/​blog​/​post-2 への遷移時パラメータを使用する loader
3手動再検証useRevalidatorフック使用時現在のページの全 loader
4フォーカス復帰ブラウザタブにフォーカスが戻った時(オプション)指定した loader

この自動再検証により、常に最新のデータが表示されることが保証されますね。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Form as フォーム
  participant Action as action
  participant Loader as loader
  participant UI as UI表示

  User->>Form: 記事を投稿
  Form->>Action: POST送信
  Action->>Action: データ保存
  Action-->>Form: 成功レスポンス

  Note over Action,Loader: action完了後に<br/>自動再検証

  Form->>Loader: loader再実行
  Loader->>Loader: 最新データ取得
  Loader-->>UI: 更新されたデータ
  UI->>User: 最新の記事一覧表示

この図から、action の実行後に自動的に loader が再実行され、UI が更新されることがわかりますね。

図で理解できる要点:

  • action 完了後は自動的に loader が再実行される
  • ユーザーは常に最新のデータを見ることができる
  • 開発者は手動でデータ更新処理を書く必要がない

手動での再検証

typescriptimport { useRevalidator } from '@remix-run/react';

export default function Dashboard() {
  const revalidator = useRevalidator();

  const handleRefresh = () => {
    // 手動でデータを再検証
    revalidator.revalidate();
  };

  return (
    <div>
      <button
        onClick={handleRefresh}
        disabled={revalidator.state === 'loading'}
      >
        {revalidator.state === 'loading'
          ? '更新中...'
          : 'データを更新'}
      </button>
    </div>
  );
}

useRevalidatorフックを使用すると、任意のタイミングでデータを再検証できます。

エラーハンドリングの実装

Remix では、エラーハンドリングを階層的に実装できます。

ルートレベルのエラーハンドリング

typescript// app/routes/blog.$slug.tsx
import {
  isRouteErrorResponse,
  useRouteError,
} from '@remix-run/react';

export function ErrorBoundary() {
  const error = useRouteError();

  if (isRouteErrorResponse(error)) {
    // HTTPステータスコードに応じた処理
    if (error.status === 404) {
      return (
        <div className='error-404'>
          <h1>記事が見つかりません</h1>
          <p>
            お探しの記事は存在しないか、削除された可能性があります。
          </p>
          <Link to='/blog' className='button'>
            記事一覧に戻る
          </Link>
        </div>
      );
    }

    if (error.status === 500) {
      return (
        <div className='error-500'>
          <h1>サーバーエラーが発生しました</h1>
          <p>
            一時的な問題が発生している可能性があります。
          </p>
          <p>しばらく待ってから再度お試しください。</p>
        </div>
      );
    }
  }

  // その他の予期しないエラー
  return (
    <div className='error-unknown'>
      <h1>予期しないエラーが発生しました</h1>
      <p>申し訳ございません。問題が発生しました。</p>
      <details>
        <summary>エラー詳細</summary>
        <pre>
          {error instanceof Error
            ? error.message
            : 'Unknown error'}
        </pre>
      </details>
    </div>
  );
}

各ルートにErrorBoundaryを実装することで、そのルート固有のエラーハンドリングが可能になります。 エラーが発生しても、アプリケーション全体がクラッシュすることなく、適切なエラーメッセージが表示されますね。

グローバルエラーハンドリング

typescript// app/root.tsx
export function ErrorBoundary() {
  const error = useRouteError();

  return (
    <html lang='ja'>
      <head>
        <meta charSet='utf-8' />
        <meta
          name='viewport'
          content='width=device-width, initial-scale=1'
        />
        <title>エラーが発生しました</title>
      </head>
      <body>
        <div className='global-error'>
          <h1>アプリケーションエラー</h1>
          <p>予期しない問題が発生しました。</p>
          {process.env.NODE_ENV === 'development' && (
            <details>
              <summary>開発者向け情報</summary>
              <pre>
                {error instanceof Error
                  ? error.stack
                  : JSON.stringify(error, null, 2)}
              </pre>
            </details>
          )}
        </div>
      </body>
    </html>
  );
}

ルートレベルでキャッチされなかったエラーは、グローバルなErrorBoundaryで処理されます。

まとめ

本記事では、Remix の核となる 3 つの仕組み「ルーティング」「データロード」「アクション」について、図解を交えながら詳しく解説してきました。

Remix の主要な特徴

Remix は、以下のような特徴により、モダンな Web 開発を実現します。

ファイルベースルーティング:

  • 直感的なファイル構造によるルート定義
  • ネストされたルートによるレイアウト共有
  • 動的パラメータのサポート

loader 関数によるデータロード:

  • サーバーサイドでのデータ取得
  • 並列実行によるパフォーマンス最適化
  • 型安全なデータアクセス

action 関数によるデータ変更:

  • Web 標準に基づいたフォーム処理
  • サーバーサイドでのバリデーション
  • 自動的なデータ再検証

Remix を使うメリット

従来の React 開発と比較して、Remix は以下のようなメリットを提供しますね。

#項目従来の方法Remix
1データフェッチクライアントサイド(useEffect)サーバーサイド(loader)
2状態管理手動管理が必要自動管理
3フォーム処理複雑なコードシンプルな宣言的記述
4エラーハンドリングコンポーネントごとに実装階層的な一元管理
5パフォーマンスウォーターフォール問題並列データフェッチ
6SEO 対応追加設定が必要デフォルトで SSR

今後の学習

Remix の仕組みを理解したら、次のステップとして以下のトピックに取り組むことをお勧めします。

認証・認可:

  • セッション管理の実装
  • ログイン/ログアウト機能
  • 保護されたルートの作成

パフォーマンス最適化:

  • キャッシュ戦略の設定
  • プリフェッチの活用
  • レスポンスの最適化

デプロイメント:

  • 各種プラットフォームへのデプロイ方法
  • 環境変数の管理
  • 本番環境の設定

Remix は、Web 標準を重視しながら、開発者体験とユーザー体験の両方を向上させる優れたフレームワークです。 本記事で学んだ基本的な仕組みを土台に、さらに高度な機能にもチャレンジしていただければと思います。

これから Remix を使った Web 開発を始める皆さまの一助となれば幸いです。

関連リンク