コンテンツにスキップ

Remix@フレームワーク

はじめに

本サイトにつきまして、以下をご認識のほど宜しくお願いいたします。


01. Remixとは

Reactのreact-routerを拡張したフレームワークである。

Remixの90%はreact-routerとのこと。

ラッパーを減らし、ブラウザがデフォルトで提供する関数やオブジェクト、HTML要素をそのまま使用できるようになっている。

export async function action({request}) {
  // formData関数はブラウザからデフォルトで提供されている
  const formData = await request.formData();
}
<form method="post"></form>


02. Remixの仕組み

アーキテクチャ

SSRのアプリケーションで以下の順に処理を実行し、データの取得からブラウザレンダリングまでを実施する。

  1. ローダー: レンダリング前、APIからデータを取得する。
  2. コンポーネント: レンダリング処理を実行する。
  3. アクション: レンダリング後のブラウザ操作に応じて、デザインパターンのコントローラーのようにクエリストリングやリクエストコンテキストを受信し、DBのデータを変更する。また、レスポンスをコンポーネントに渡す。
  4. ローダー: ブラウザ操作に応じて、アクションからデータを取得する。


ローダー

▼ ローダーとは

ローダーは、loader関数として定義できる。

レンダリング前にAPIからデータを取得し、またブラウザ操作に応じてアクションからデータを取得する。

各エンドポイントごとに定義できる。

DBにクエリを送信し、データを取得できる。

認証処理がある場合、ローダーの前に実行する必要がある。

*実装例*

import {json} from "@remix-run/node";
import {useLoaderData} from "@remix-run/react";

// ローダーでレンダリング前にデータを取得する
export const loader = async () => {
  return json({
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  });
};

▼ ロギング

ローダー内でconsole.log関数を実行すると、バックエンドの実行ログとして出力され、ブラウザのコンソールには出力されない。

▼ useLoaderData

ローダーで取得したデータを出力できる。

*実装例*

import {json} from "@remix-run/node";
import {useLoaderData} from "@remix-run/react";

// ローダーでレンダリング前にデータを取得する
export const loader = async () => {
  return json({
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  });
};

// コンポーネントで、レンダリング処理を実行する
export default function Posts() {
  const {posts} = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}


コンポーネント

▼ コンポーネントとは

コンポーネントは、レンダリング処理を実行する。

内部的にはReactのコンポーネントが使用されている。

import {json} from "@remix-run/node";
// 内部的にはReactのコンポーネントである。
import {useLoaderData} from "@remix-run/react";

// ローダーでレンダリング前にデータを取得する
export const loader = async () => {
  return json({
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  });
};

// コンポーネントで、レンダリング処理を実行する
export default function Posts() {
  const {posts} = useLoaderData<typeof loader>();
  return (
    <main>
      <h1>Posts</h1>
    </main>
  );
}

▼ ロギング

コンポーネント内でconsole.log関数を実行すると、ブラウザのコンソールに出力され、バックエンドの実行ログには出力されない。

▼ アクションではなくコンポーネントに実装するべき処理

バックエンドのデータを変更する必要がないような外部API通信処理は、アクションではなくコンポーネントに実装するべきである。


アクション

▼ アクションとは

レンダリング後のブラウザ操作に応じて、デザインパターンのコントローラーのようにクエリストリングやリクエストのコンテキストを受信し、DBのデータを変更する。

また、レスポンスのデータをコンポーネントに渡す。

componentを同じファイルに実装する以外に、.serverディレクトリに切り分ける方法もある。

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

import { TodoList } from "~/components/TodoList";
import { fakeCreateTodo, fakeGetTodos } from "~/utils/db";

// ローダーでレンダリング前にデータを取得する
// レンダリング後のブラウザ操作でactionが実行され、actionの結果を取得する
export async function loader() {
  return json(await fakeGetTodos());
}

// コンポーネントで、レンダリング処理を実行する
export default function Todos() {

  // useLoaderDataでloaderによる取得データを出力する
  const data = useLoaderData<typeof loader>();

  // Todoリストを出力する
  return (
    <div>
      <TodoList todos={data} />
      <Form method="post">
        <input type="text" name="title" />
        {/*
          Create Todoボタンを設置する
          同一ファイルのactionをコールする。
        */}
        <button type="submit">Create Todo</button>
      </Form>
    </div>
  );
}

// アクションで、受信したリクエストに応じたレスポンス処理を実行する
export async function action({request}: ActionFunctionArgs) {

  const body = await request.formData();

  try {

    const todo = await fakeCreateTodo({
      title: body.get("title"),
    });

    return redirect(`/todos/${todo.id}`);
  } catch (error) {

    // さまざまな型のerrorを処理できるように対処する
    const errorMessage = error instanceof Error ? error.message : String(error);
    console.error(errorMessage, body.get("title"))
    // error.statusが未定義の場合、一律で500ステータスとして扱う
    return json({ error: errorMessage }, { status: error.status ?? 500});
  }
}

▼ ロギング

アクション内でconsole.log関数を実行すると、バックエンドの実行ログとして出力され、ブラウザのコンソールには出力されない。


03. ディレクトリ構成

構成

appディレクトリ配下はユーザーで構成する必要があり、例えば以下のようにする。

.
├── app/
│   ├── components         # フロントエンドで使用する汎用的なreactコンポーネント
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── models             # DBモデル、型定義 (ドメインロジックのため、永続化処理以外には依存させない)
│   ├── openapi            # OpenAPI仕様書の生成処理
│   ├── root.tsx
│   ├── routes             # フロントエンド(例:レンダリング処理)とバックエンド(例:APIエンドポイント処理)の関数
│   ├── services           # フロントエンド/バックエンドで使用する汎用的なデザインパターン
│   ├── styles             # フロントエンドで使用するCSS、Tailwind、
│   └── utils              # フロントエンド/バックエンドで使用する汎用的で小さな関数

├── prisma/ # モデルの定義
...


root.tsx

アプリケーションのルートである。

linkタグ、metaタグ、scriptタグを定義する。


entry.client.tsx

マークアップファイルのハイドレーション処理のエントリーポイントである。


entry.server.tsx

▼ entry.server.tsxとは

レスポンス作成処理のエントリーポイントである。

RemixServerで設定を変更できる。

defaultエクスポート

entry.serverファイルのdefaultエクスポート関数は、RemixのSSRモードのエントリーポイントになる。

handleRequestという名前であることが多いが、どんな名前でもよい。

defaultエクスポート関数を複数定義することはできない。

▼ handleDataRequest

Remixの内部で実行され、JSONデータを作成し、Remixのフロントエンド処理に渡す。

useLoaderData関数やuseFetcher().data関数で取得できる。

export function handleDataRequest(response: Response, {request, params, context}: LoaderFunctionArgs | ActionFunctionArgs
) {
  response.headers.set("X-Custom-Header", "value");
  return response;
}


04. セットアップ

React Router v6

Remix v2を@remix-run/nodeパッケージからインポートする。

import {redirect} from "@remix-run/node";


React Router v7以降

Remix自体がReact Routerに統合されたため、react-routerパッケージをインポートする。

import {redirect} from "react-router";


05. ルーティング

UIとAPI

Rえmixでは、ブラウザまたはAPIへのルーティングが区別されず、両方を兼ねている。

ただし、ファイル名によって区別することもできる。

app/routes/api.<任意のパス>ファイルまたはapp/routes/api/<任意のパス>ファイルを作成する。

このファイルの処理は、APIとして処理される。


ドッド分割

_index.tsx

ルートパスになる。

app/                        # URLパス
├── routes/
│   ├── _index.tsx          # /
└── root.tsx

<ルート以降のパス>.tsx

ルート以降のパスを設定する。

app/                        # URLパス
├── routes/
│   ├── _index.tsx          # /
│   ├── home.tsx            # /home
│   ├── home.contents.tsx   # /home/contents
└── root.tsx

*実装例*

// <ルート以降のパス>._index.tsx
export default function Foo() {
  // 返却するHTML要素
  return (
    <main>
      <h1>Foo</h1>
    </main>
  );
}

<ルート以降のパス>.<変数>.tsx (動的セグメント)

動的にURLを決定する。

URLに規則性があるようなページに適する。

app/                        # URLパス
├── routes/
│   ├── _index.tsx          # /
│   ├── home.tsx            # /home
│   ├── home.contents.tsx   # /home/contents
│   ├── user.$id.tsx        # /user/{任意の値}
└── root.tsx

*実装例*

// posts.$postId.tsxファイル
export default function Post() {
  return (
    <div>
      <h1 className="font-bold text-3xl">投稿詳細</h1>
    </div>
  );
}

以下のURLでページをレンダリングできる。

  • /posts/1
  • /posts/2
  • /posts/3

▼ 子の_<ルート以降のパス>.tsx

子のファイル名にプレフィクスとして _ (パスレスルート) をつける。

これにより、親からレイアウトを引き継ぎつつ、パスは引き継がない。

*実装例*

_home.auth.tsxファイルは、親のhome.tsxファイルのレイアウトを引き継いでいる。

しかし、/home/authパスではなく、/authパスになる。

app/                       #  URLパス                   引き継ぐレイアウト
├── routes/
│   ├── _index.tsx         #  /                        root.tsx
│   ├── home.tsx           #                           root.tsx
│   ├── home._index.tsx    #  /home                    home.tsx
│   ├── home.contents.tsx  #  /home/contents           home.tsx
│   ├── home_.mine.tsx     #  /home/mine               root.tsx
│   ├── _home.auth.tsx     #  /auth                    home.tsx # 親からパスを引き継がない
│   ├── user.$id.tsx       #  /user/{任意の値}          root.tsx
│   ...

└── root.tsx

▼ 親の_<ルート以降のパス>.tsx (親がパスレスルート)

親のファイル名にプレフィクスとして _ (パスレスルート) をつける。

*実装例*

_auth.<任意の名前>.tsxファイルは、親の_auth.tsxファイルのレイアウトを引き継いでいる。

しかし、全てのファイルのURLにauthが含まれない。

app/                               # URLパス
├── routes                         #
│   ├── _auth.tsx                  #
│   ├── _auth.login.tsx            # /login
│   ├── _auth.password.reset.tsx   # /password/reset
│   ├── _auth.register.tsx         # /register
...
// _auth.tsxファイル
import {Outlet} from "@remix-run/react";

import {SiteFooter, SiteHeader} from "~/components";

export default function AuthCommon() {
  return (
    <div className="grid grid-rows-[auto_1fr_auto] h-dvh">
      <SiteHeader />
      {/* Outletに子 (login、password.reset、register) を出力する */}
      <Outlet />
      <SiteFooter />
    </div>
  );
}


ディレクトリ分割

ディレクトリ名がパスとして認識される。


06. componentの種類

ユーザー定義

Remixがコンポーネントであることを認識するために、名前の先頭を大文字する。


Form

formタグをレンダリングする。

action値を省略した場合、フォームの入力データは他に送信されず、そのコンポーネント内のみで処理される。

action値を/foos?indexパスとした場合、routes/foos/index.jsxファイルにデータを送信する。

一方で、action値を/foosパスとした場合、routes/foos.jsxファイルにデータを送信する。

*実装例*

ここではaction値を省略している。

import {Form} from "@remix-run/react";

function NewEvent() {
  return (
    <Form action="/events" method="post">
      <input name="title" type="text" />
      <input name="description" type="text" />
    </Form>
  );
}


Meta

Webページのmetaタグ (Webサイト名、説明など) をレンダリングする。

import {Meta} from "@remix-run/react";

export default function Root() {
  return (
    <html>
      <head>
        <Meta />
      </head>
      <body></body>
    </html>
  );
}


Outlet

親ページ内に子ページをレンダリングする。

import {Outlet} from "@remix-run/react";

export default function SomeParent() {
  return (
    <div>
      <h1>Parent Content</h1>

      <Outlet />
    </div>
  );
}


LocalStorageやSessionStorageではなくCookie

RemixはSSRアプリケーションを作成する。

SSRでは、ブラウザのLocalStorageやSessionStorageを操作できない。

代わりに、ブラウザのCookie、サーバーのメモリ、サーバー上のファイルなどに資格情報を保存することになる。


ブラウザのCookieに資格情報を保存する。

export const cookieSessionStorage = createCookieSessionStorage({
  cookie: {
    name: "__session",
    httpOnly: true,
    path: "/",
    sameSite: "lax",
    secrets: [process.env.SESSION_SECRET],
  },
});

▼ サーバーのメモリに保存する場合

サーバーのメモリに資格情報を保存する。

export const memorySessionStorage = createMemorySessionStorage({
  cookie: sessionCookie,
});

▼ サーバー上のファイルに保存する場合

サーバー上のファイルに資格情報を保存する。

export const memorySessionStorage = createFileSessionStorage({
  dir: "/app/sessions",
  cookie: sessionCookie,
});


08. エラー

データ名 説明
state ステータスコード 405
statusText ステータスコードのエラーメッセージ Method Not Allowed
data 詳細なエラー Error: *****