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のアプリケーションで以下の順に処理を実行し、データの取得からレンダリングまでを実施する。
- ローダー: レンダリング前、APIからデータを取得する。
- コンポーネント: レンダリング処理を実行する。
- アクション: レンダリング後のブラウザ操作に応じて、デザインパターンのコントローラーのようにクエリストリングやリクエストコンテキストを受信し、DBのデータを変更する。また、レスポンスをコンポーネントに渡す。
- ローダー: ブラウザ操作に応じて、アクションからデータを取得する。
ローダー¶
▼ ローダーとは¶
ローダーは、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>
);
}
07. Cookieを使用した認証¶
LocalStorageやSessionStorageではなくCookie¶
RemixはSSRアプリケーションを作成する。
SSRでは、Web Storage APIと通信できず、ブラウザのLocalStorageやSessionStorageを操作できない。
代わりに、ブラウザのCookie、サーバーのメモリ、サーバー上のファイルなどに資格情報を保存することになる。
Cookieの作成と保存¶
▼ ブラウザの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";