コンテンツにスキップ

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 ブラウザ上 - - - 最初、ブラウザからRemixにリクエストを送信する。ほかに、アクション中の remix-link コンポーネントが、URLクエリストリングのページ値(?page=n)が書き換える。
2 ローダー app/routes ルーティング Controller レンダリング前にバックエンドでデータを処理し、return json() でフロントエンドにデータを渡す。ローダーの段階でデータを用意しておき、フロントエンドではデータ表示ロジックだけを実装すると、ブラウザの描画の性能が上がる。
3 remixコンポーネント app/components UIロジック、CSSスタイリングロジック、状態管理ロジック View + ViewModel レンダリング処理を実行する。reactコンポーネントとは区別する。また、app/components にあるreactコンポーネント(UIレンダリングロジック、状態管理ロジック)を呼び出す。 生成したページをブラウザに返信する。
4 ブラウザ上 - - - Remixからページを取得し、表示する
5 アクション app/routes ルーティング Controller レンダリング後のブラウザ操作に応じて、デザインパターンのコントローラーのようにクエリストリングやリクエストコンテキストを受信し、DBのデータを変更する。また、レスポンスをremixコンポーネントに渡す。
6 ローダー app/routes UIロジック、CSSスタイリングロジック、状態管理ロジック View + ViewModel ブラウザ操作に応じて、アクションからデータを取得する。
7 remixコンポーネント app/components UIロジック、CSSスタイリングロジック、状態管理ロジック View + ViewModel 2番に同じ

▼ SSRの場合の詳細な流れ

以下は、SSR 時にログイン後のトップページの表示に関わる処理である。

SSR のため、サーバーレンダリングとしている。

リクエスト受信:./app/entry.server.tsx
- クライアントがブラウザまたはBotかを判定
- UIレンダリングパターン選択
⬇︎
⬇︎
ミドルウェア処理:./app/middleware/tokenVerification.ts への切り分け
- アクセストークン署名検証
- 未認証時は認証を要求
⬇︎
⬇︎
データ処理:./app/routes + ./app/models
- ルーティング
- DB操作
⬇︎
⬇︎
ミドルウェア処理:./app/middleware/context.ts
- FlashMessage操作
- レスポンスの`Set-Cookie`ヘッダー操作
⬇︎
⬇︎
サーバーレンダリング:./app/routes + ./app/components
⬇︎
⬇︎
レスポンス返信:./app/entry.server.tsx


ローダー

▼ ローダーとは

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

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

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

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

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

*実装例*

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

// ローダー
export const loader = async () => {
  // バックエンドでデータを処理する
  // ローダーの段階でデータを用意しておき、フロントエンドではデータ表示ロジックだけを実装すると、ブラウザの描画の性能が上がる
  const data = {
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  };

  return json(data);
};

▼ ロギング

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

▼ useLoaderData

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

*実装例*

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

// ローダーでレンダリング前にバックエンドでデータを処理する
export const loader = async () => {

  // バックエンドでデータを処理する
  // ローダーの段階でデータを用意しておき、フロントエンドではデータ表示ロジックだけを実装すると、ブラウザの描画の性能が上がる
  const data = {
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  }

  return json(data);
};

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


remixコンポーネント

▼ remixコンポーネントとは

React を使用して実装された Remix 専用のビルトインコンポーネントである。

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

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

// ローダー
export const loader = async () => {

  // バックエンドでデータを処理する
  // ローダーの段階でデータを用意しておき、フロントエンドではデータ表示ロジックだけを実装すると、ブラウザの描画の性能が上がる
  const data = {
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  }

  return json(data);
};

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

▼ 命名

機能内にある URL を表すようなコンポーネント名にする。

例えば、Foo という機能があり、その機能内にレポート表示 URL があれば、FooReport というコンポーネント名にする。

// レポートのURLで使用する
export default function FooReport() {
  // 何らかの実装
}

▼ ロギング

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

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

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

▼ props

親コンポーネントから子コンポーネントにわたす値を特別に props という。

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

// ローダー
export const loader = async () => {
  // バックエンドでデータを処理する
  const data = {
    posts: [
      {
        slug: "my-first-post",
        title: "My First Post",
      },
      {
        slug: "90s-mixtape",
        title: "A Mixtape I Made Just For You",
      },
    ],
  };

  return json(data);
};

// 親のremixコンポーネントで、ローダーからデータを取得する
export default function Posts() {
  const {posts} = useLoaderData<typeof loader>();

  return <PostList posts={posts} />;
}

// 子のremixコンポーネントで、親コンポーネントからpropsとしてpostsを受け取る
const PostList = ({posts}) => {
  return (
    <main>
      <h1>Posts</h1>
      <ul>
        {posts.map((post) => (
          <li key={post.slug}>{post.title}</li>
        ))}
      </ul>
    </main>
  );
};


アクション

▼ アクションとは

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

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

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() {

  // ローダーでレンダリング前にバックエンドでデータを処理する
  const data = await fakeGetTodos()

  return json(data);
}

// remixコンポーネントで、レンダリング処理を実行する
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. アーキテクチャ

Remix独自のMVCアーキテクチャ

Remix は独自の MVC アーキテクチャを採用している。

各 route ファイルが独立した小さな MVC になっている。

View
# routesのcomponentの処理などが相当


Controller
# routesのloaderやactionの処理が相当


Model
# modelsなどが相当
# ドメイン層のモデルとインフラストラクチャ層の永続化処理が結合している


レイヤードアーキテクチャ風

レイヤードアーキテクチャにしたらどうだろうか。

UserInterface層
# routesのcomponent、loader、actionの処理の中で、入力と出力の処理が相当


Application層
# routesのcomponent、loader、actionの処理の中で、modelsとの調整処理が相当


Domain層
# アプリケーション独自のモデルが相当


Infrastructure層
# データベース接続、ロギング、ファイルシステム操作、外部API通信などが相当


クリーンアーキテクチャ風


04. UIレンダリングパターン

Remix では、SSR モード、CSR モード、SSG モードがある。

CSR モードと SSG モードは厳密な区分ではなく、Remix 独自の擬似的なモードである。

それぞれのモードで、entry.server.tsx ファイルと entry.client.tsx ファイルが関与する。

entry.server.tsx ファイル entry.client.tsx ファイル
SSR サーバーレンダリング関連の処理 ハイドレーション関連の処理
擬似的CSR 最小限のサーバーレンダリング関連の処理 クライアントレンダリング関連の処理
擬似的SSG ビルド時にサーバーレンダリング関連の処理 ハイドレーション関連の処理


05. ディレクトリ構成

構成

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

レイヤードアーキテクチャに沿った依存方向やディレクトリ構成でアプリケーションを設計したいが、本アプリケーションが採用している Remix にはフレームワークとしての設計ルールがあり、これも尊重する必要がある。

レイヤードアーキテクチャに沿った依存方向で設計できるが、Remix は 3 つの要素(ローダー、アクション、コンポーネント)を使用した設計方法を強制する点で、ディレクトリ構成は完全にはレイヤードアーキテクチャに沿うことはできない。

そこで、Remix を尊重しつつ、ディレクトリ構成をレイヤードアーキテクチャに可能な限り近づけることとする。

├── app
│   ├── authenticators     # 認証処理ロジック
│   ├── components         # 汎用的なUIレンダリング、CSSスタイリングのロジック、UI入力値バリデーション
│   │   ├── core           # 汎用UIコンポーネント
│   │   ├── forms          # フォームコンポーネント
│   │   ├── layouts        # レイアウトコンポーネント
│   │   └── validators     # UI入力値バリデーション
│   ├── constants          # さまざまなグローバルな値
│   ├── entry.client.tsx
│   ├── entry.server.tsx
│   ├── database           # データベースへの接続処理のロジック
│   ├── models             # ORMモデル、ORMを使用したRead処理やWrite処理のロジック
│   ├── root.tsx
│   ├── routes             # APIルーティングまたはページング処理、状態管理、UIレンダリング、CSSスタイリング、API入力値バリデーションに関するロジック
│   ├── styles             # CSSやTailwindなど
│   ├── usecases           # routeディレクトリのapiルートまたはuiルートから呼び出すユースケース
│   └── utils              # 『薄い関数』『その他、汎用的な非機能ロジックの関数』など

├── cypress # E2Eテストのテストコード
├── mocks   # E2Eテストのための外部APIモック
├── prisma  # Prismaのデータベーススキーマ
├── test    # テストコードのセットアップ
└── vitest  # ユニットテストコード

routes ディレクトリについて

routes ディレクトリには、API ルーティングまたはページング処理、状態管理、UI レンダリング、CSS スタイリング、API 入力値バリデーションに関するロジックを置く。

routes ディレクトリのアーキテクチャは Remix の仕組み(ローダー、アクション、コンポーネント)の影響を特に受ける。

フロントエンドまたはバックエンドのプレゼンテーション層のロジックがローダー、アクション、コンポーネントに配置される。

// 例:app/routes/foo.tsx

// ローダー
export const loader = wrapLoader(async ({authenticatedUser, request}) => {
  // 中略
  // バックエンド領域のプレゼンテーション層に相当
  // 下書き系 (UI特有のユースケースで、APIでは不要) ・閲覧系ユースケースのページング処理のロジック
  // 中略
});

// アクション
export const action = wrapAction(async ({request, authUser}) => {
  // 中略
  // バックエンド領域のプレゼンテーション層に相当
  // 登録・更新・削除系ユースケースのページング処理のロジック
  // 中略
});

// コンポーネント
export default function FooComponent() {
  // 中略
  // フロントエンド領域のプレゼンテーション層に相当
  // 状態管理、UIレンダリング、CSSスタイリングのロジック
  // 中略
}

usecases ディレクトリについて

usecases ディレクトリには、routes ディレクトリの下書き・閲覧・作成・更新・削除系ユースケースの種類に応じて呼び出すためのユースケース処理を置く。

  • 下書き系:getDraftXxx (UI 特有のユースケースで、API では不要)
  • 閲覧系:getXxxlistXxxsearchXxx など
  • 作成系:registerXxx
  • 更新系:changeXxx
  • 削除系:deleteXxx

routes ディレクトリには、API ルーティングのための api ルートとページング処理のための ui ルートがある。

それぞれのルートに対応したユースケース処理のファイル(uiXxxUsecaseapiXxxUsecase)を作成すること、


root.tsx

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

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


entry.client.tsx

すべてのモードで使用する (CSR モードだけではない) 。

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


entry.server.tsx

▼ entry.server.tsxとは

すべてのモードで使用する (SSR モードや SSG モードだけではない) 。

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

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;
}


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

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


06. セットアップ

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";


07. ルーティング

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>
  );
}


ディレクトリ分割

▼ 基本ルール

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

▼ サブディレクトリ

Remix v1 ではサブディレクトリにわけることができる。

以下によると、Remix v2 はデフォルトでは routes にサブディレクトリを作れない。

ただ、https://github.com/kiliman/remix-flat-routes を使うと、サブディレクトリを作ることはできる。

app/routes-hybrid-files/
├── _auth+
│   ├── forgot-password.tsx
│   └── login.tsx
├── _public+
│   ├── _layout.tsx
│   ├── about.tsx
│   ├── contact[.jpg].tsx
│   └── index.tsx
├── project+
│   ├── _layout.tsx
│   ├── parent.child
│   │   └── index.tsx
│   └── parent.child.grandchild
│       ├── index.tsx
│       └── styles.css
└── users+
├── $userId.tsx
├── $userId_.edit.tsx
├── _layout.tsx
└── index.tsx

Remix の仕様ではディレクトリ構造やファイル名がエンドポイントに影響してしまう。

エンドポイントを崩さず routes にサブディレクトリを作る場合、ファイル名を部分的に変える必要があり、エンドポイントが一目ではわかりにくくなる。

現在の状態であれば、ファイルは多くて辛いが、エンドポイントがファイル名から一目でわかる。

よって、ひとまず現在のままとする。

(Remix は規模が大きくなると、routes に辛さが出そう...)

Remixはとても小さなサイトや個人のブログだけを想定しているのですか?Remixは全般的に好きだけど、フラットルートを使うようになったことで、正直Remixが使いづらくなった。

https://github.com/remix-run/remix/discussions/8473#discussioncomment-9174522


08. 通信方式

リクエスト/レスポンス方式

記入中...


ストリーミング方式

▼ レスポンスストリーミング

バックエンドからデータの取得に時間がかかる場合、defer によるレスポンスストリーミングを使用する。

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

export const loader = async () => {
  const heavyProceccingData = heavyProceccing();

  return defer({
    critical: "data",
    slowPromise: heavyProceccingData,
  });
};


08. 状態管理

  • 特定の値を二重管理していないか
  • コンポーネントにそのまま出力すればいい値を状態としてわざわざ管理していないか


09. remixコンポーネントの種類

ユーザー定義

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


Remix Formコンポーネント

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

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

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>
  );
}


URL を書き換える。

ページネーション処理に使える。

Link コンポーネントが URL クエリストリングのページ値を書き換えると、Remix のローダーがそれを検知し、新しい URL クエリストリングで再リクエストを送信することで別のページが表示される。

*実装例*

import {Link, useSearchParams} from "@remix-run/react";

type PageLinkProps = {
  page: number;
  children: React.ReactNode;
  resetKeys?: string[];
};

export function PageLink({page, children, resetKeys = []}: PageLinkProps) {
  const [searchParams] = useSearchParams();
  const next = new URLSearchParams(searchParams);

  for (const key of resetKeys) {
    next.delete(key);
  }

  next.set("page", String(page));

  // LinkコンポーネントがURLクエリストリングのページ値を書き換える
  return (
    <Link
      to={{search: `?${next.toString()}`}}
      preventScrollReset
      prefetch="intent"
    >
      {children}
    </Link>
  );
}
// RemixのローダーがURLの変更を検知する
export async function loader({request}: LoaderFunctionArgs) {

  // 新しいクエリストリングをURLから取得する
  const url = new URL(request.url);
  const page = Number(url.searchParams.get("page") ?? "1");

  return typedjson({
    // 新しいクエリストリングで再リクエストを送信する
  }
}


Remix Metaコンポーネント

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

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

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


Remix Outletコンポーネント

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

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

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

      <Outlet />
    </div>
  );
}


10. hook

hookをコールしてはいけない場所

Remix は hook を同じ順番、同じ回数でコールする必要がある。

  • onClick などをイベントハンドラ
  • if などの条件分
  • formap などの走査
  • コンポーネントの props


revalidator

指定した間隔で loader を再実行する。

リアルタイム更新で役立つ。

loader を再実行するからといって、ページをリロードしているわけではない。


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,
});


11. モデル

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},
  });
}


12. エラー

バックエンド

▼ エラーハンドリング

データ名 説明
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";