コンテンツにスキップ

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/         # フロントエンドで使用する汎用的なコンポーネント
│   │   ├── share/          # 各コンポーネントで使用するロジック
│   │   ├── decorator/      # 認証などの補助的なコンポーネント
│   │   ├── forms/          # 入力フォームコンポーネント
│   │   ├── layouts/        # 画面レイアウトコンポーネント
│   │   └── validators/     # 入力フォームの検証ロジック
│   │
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── hooks/              # componentsディレクトリで使用するフック処理 (setState系関数)
│   ├── models/             # DBモデル、型定義 (ドメインロジックのため、永続化処理以外には依存させない)
│   ├── openapi/            # OpenAPI仕様書の生成処理
│   ├── root.tsx
│   ├── routes/             # フロントエンド (例:レンダリング処理) とバックエンド (例:APIエンドポイント処理) の関数。関数はローダー、コンポーネント、アクションに分類できる。
│   ├── services/           # フロントエンド/バックエンドで使用する『デザインパターン』『外部APIとの通信』『認証』『prisma.server.ts』など
│   ├── 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;
}


その他の任意ディレクトリ

▼ index.ts

各ディレクトリのエントリポイントとして使える。

index.tsファイルでexportしておくと、コールする側がディレクトリ単位でインポートできるようになる。

// utils/index.ts
export {logger} from "./logger";
export {logger} from "./errorHandler";
// ファイルを個別に指定する必要がなくなる
import {fooLogger, fooErrorHandler} from "~/utils";


レイヤードアーキテクチャにすると

app
├── application
│   └── projects
│       ├── ports.ts
│       └── usecases.ts

├── domain
│   └── projects
│       ├── rules.ts
│       └── types.ts

├── infrastructure # modelsディレクトリのファイルをinfrastructureディレクトリに配置する
│   ├── prisma.server.ts
│   └── projects
│       ├── repo.server.ts # ドメインモデルをCRUDする
│       └── DTO.ts # ドメインモデルのデータをDTOに詰め替える

├── presentation # routesディレクトリのファイルをpresentationディレクトリに配置する
│   └── projects
│       ├── route.server.ts
│       ├── route.tsx
│       └── schema.ts

└── utils


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

Remixでは、ブラウザルーティングと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では、Web Storage APIと通信できず、ブラウザの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. モデル

prismaによるスキーマ

Prisma ORMを使用して、データベースのスキーマを定義する。

model User {
  id        String   @id @default(cuid())
  name      String
  email     String   @unique
  role      String   // "ADMIN" | "MEMBER"
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}


CRUD処理

▼ CREATE

ドメインモデルを作成する。

import type {User} from "@prisma/client";
import {prisma} from "~/services/prisma.server";

// CREATE
export async function createUser({
  name,
  email,
  role,
}: {
  // prismaのスキーマを使用して、引数の型を指定する
  name: User["name"];
  email: User["email"];
  role: User["role"];
  // prismaのスキーマを使用して、返却値の型を指定する
}): Promise<User> {
  return prisma.user.create({
    data: {name, email, role},
  });
}

▼ READ

DBレコードからドメインモデルを取得する。

import type {User} from "@prisma/client";
import {prisma} from "~/services/prisma.server";

// READ One
export async function getUserById({
  id,
}: {
  // prismaのスキーマを使用して、引数の型を指定する
  id: User["id"];
  // prismaのスキーマを使用して、返却値の型を指定する
}): Promise<User | null> {
  return prisma.user.findUnique({
    where: {id},
  });
}

// READ Many
export async function listUsers(): Promise<User[]> {
  return prisma.user.findMany({
    orderBy: {createdAt: "desc"},
  });
}

▼ UPDATE

ドメインモデルを変更する。

import type {User} from "@prisma/client";
import {prisma} from "~/services/prisma.server";

// UPDATE
export async function updateUser({
  id,
  name,
  email,
  role,
}: {
  // prismaのスキーマを使用して、引数の型を指定する
  id: User["id"];
  name?: User["name"];
  email?: User["email"];
  role?: User["role"];
  // prismaのスキーマを使用して、返却値の型を指定する
}): Promise<User> {
  return prisma.user.update({
    where: {id},
    data: {
      ...(name !== undefined ? {name} : {}),
      ...(email !== undefined ? {email} : {}),
      ...(role !== undefined ? {role} : {}),
    },
  });
}

▼ DELETE

ドメインモデルを削除する。

import type {User} from "@prisma/client";
import {prisma} from "~/services/prisma.server";

// DELETE
export async function deleteUser({
  id,
}: {
  // prismaのスキーマを使用して、引数の型を指定する
  id: User["id"];
  // prismaのスキーマを使用して、返却値の型を指定する
}): Promise<User> {
  return prisma.user.delete({
    where: {id},
  });
}


09. エラー

バックエンド

▼ エラーハンドリング

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


フロントエンド

▼ ユーザー向けのメッセージ

import {
  ExclamationTriangleIcon,
  LockClosedIcon,
  MagnifyingGlassIcon
} from "@heroicons/react/20/solid";
import { isRouteErrorResponse, useRouteError } from "@remix-run/react";
import React from "react";

export function ErrorBoundary() {
  // ローダーやアクションのエラーステータスを取得する
  const error = useRouteError();
  if (isRouteErrorResponse(error)) {
    return (
      <div>
        <div
          className="flex h-[80%] w-full items-center justify-center p-5"
          data-cy="errorBoundary"
        >
          <div className="text-center">
            <div className="inline-flex justify-center p-3">
              {/* 403ステータスの場合に使用するアイコン */}
              {error.status === 403 && (
                {/* 南京錠アイコン */}
                <LockClosedIcon className="h-[20%] w-[20%] text-yellow-500" />
              )}
              {/* 404ステータスの場合に使用するアイコン */}
              {error.status === 404 && (
                {/* 虫眼鏡アイコン */}
                <MagnifyingGlassIcon
                  className="h-[20%] w-[20%] text-sky-400"
                  data-cy="notFoundError"
                />
              )}
            </div>
            <p className="mt-2 text-[24px] font-bold text-slate-800 lg:text-[38px]">
              {/* ローダーやアクションのエラーステータスのタイトルを出力する */}
              {error.data.title}
            </p>
            <p className="mt-5 text-slate-600 lg:text-lg">
              {/* ローダーやアクションのエラーステータスのメッセージを出力する */}
              {error.data.message}
            </p>
          </div>
        </div>
      </div>
    );
  }

  return (
    <div>
      <div className="flex h-[80%] w-full items-center justify-center p-5">
        <div className="text-center">
          <div className="inline-flex justify-center p-3">
            {/* 異常アイコン */}
            <ExclamationTriangleIcon className="h-[20%] w-[20%] text-red-600" />
          </div>
          <p className="mt-2 text-[24px] font-bold text-slate-800 lg:text-[38px]">
            500 Internal Server Error
          </p>
          <p className="mt-5 text-slate-600 lg:text-lg">
            予期せぬエラーが発生しました
          </p>
        </div>
      </div>
    </div>
  );
}
// Remixでは、ErrorBoundaryという名前でコールする必要がある
export {ErrorBoundary} from "~/components/ErrorBoundary";