本文書はReactベースのフレームワークRemixの入門書向けの内容です。Reactの基礎知識を持っていることを前提にしているためReactに関する詳細な機能説明は行っていませんがコードでつまずかないようにJavaScriptを利用したシンプルなコードを利用しています。Remixの基本的な機能の説明(Remixで利用できるHook)を中心に説明を行なっています。

現在のRemixのバージョンはv2ですが本文書はv1で動作確認を行っています。

Remixとは

RemixはReactライブラリをベースにしたフルスタックフレームワークで、Sever Side Rendering(サーバーサイドレンダリング)を基本としています。ルーティングについてはReact Router v6が利用されているのでNested RouteやReact Router v6で利用できるAPIはRemixでも利用することができます。

ReactのフレームワークのNext.jsやVue.jsのフレームワークのNuxt.jsなどと同様にファイルベースのルーティングを採用している点は同じですが、ルーティングファイルにはブラウザ上で実行されるUIに関連するコードを記述するだけではなくサーバ上で行なう処理のコードを記述することができます。ルーティングファイルで記述できるサーバ上の処理にはloader関数を利用したデータのローディング(データベースへアクセスしてデータを取得)、action関数を利用したデータのmutation(ミューテーション)が含まれます。action関数の中ではPOSTリクエストで送信されたデータを受け取るだけではなくデータベースへのデータの追加から更新、削除までサーバ上の処理を記述することができます。データのロードからデータのmutation、データの表示まで1つのファイル内にすべて記述することができます。

フォームの作成について特徴があり、Reactで利用されるuseState、useRef、onSubmitイベントを利用するものではなくPHPなどで利用されて馴染みの深いHTMLフォームと同様の方法で記述することができます。これがどのようなものかは本文書を読むことで理解することができます。

プロジェクトの作成

プロジェクトの作成にはnpx create-remixコマンドを利用します。対話式にプロジェクトに関する設定を行います。最初はプロジェクトの名前を聞かれるので任意の名前を設定してください。本書ではデフォルトのmy-remix-appを利用しています。次にどのようなアプリケーションを作成したいか聞かれるので”Just the basics”を選択しています。デプロイする場所を選択することができますが本書では”Remix App Server”を選択します。次にTypeScriptかJavaScriptを選択することができます。本文書はJavaScriptを選択しています。


 % npx create-remix@latest 
Need to install the following packages:
  create-remix@latest
Ok to proceed? (y) y
? Where would you like to create your app? (./my-remix-app)
? What type of app do you want to create? 
❯ Just the basics 
  A pre-configured stack ready for production 
? Where do you want to deploy? Choose Remix App Server if you're unsure; it's 
easy to change deployment targets. (Use arrow keys)
❯ Remix App Server 
  Express Server 
  Architect (AWS Lambda) 
  Fly.io 
  Netlify 
  Vercel 
  Cloudflare Pages 
? TypeScript or JavaScript? (Use arrow keys)
❯ TypeScript 
  JavaScript 
? Do you want me to run `npm install`? (Y/n) 
//略
added 979 packages, and audited 980 packages in 1m

207 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities
💿 That's it! `cd` into "/Users/mac/Desktop/my-remix-app" and check the README for development and deploy instructions!

プロジェクトの作成が完了するとmy-remix-appフォルダが作成されます。 作成されたプロジェクトに移動して開発サーバを起動します。


% cd my-remix-app
% npm run dev
> dev
> remix dev

Watching Remix app in development mode...
💿 Built in 460ms
Remix App Server started at http://localhost:3000 (http://192.168.2.106:3000)

起動後、localhost:3000にアクセスすると以下の初期画面が表示されます。

Remix初期ページ
Remix初期ページ

SSR(サーバサイドレンダリング)とは

RemixはSSRを利用しているのでページの内容はサーバ側で生成されてブラウザに送られてくるためページのソースを見ると画面に表示されている内容のHTMLのマークアップを確認することができます。metaタグ, h1タグ, ul, liタグが含まれています。

サーバサイドレンダリングの確認
サーバサイドレンダリングの確認
ReactはSSRではなくCSR(Client Side Rendering)なのでブラウザが受け取るindex.htmlにはページのコンテンツは含まれておらず、HTMLファイルとと一緒にダウンロードするJavaScriptをブラウザ側で実行することでindex.html内の指定したDOMにコンテンツが挿入されブラウザ上に表示されます。

Reactの場合ではソースコードを確認するとbodyタグの中にはidにappを持つdiv要素以外はありません。

Reactの場合のソース
Reactの場合のソース

SSRとCSRの違いを簡単に説明しました。

root.jsxファイルの確認

package.jsonファイルを確認して利用できるコマンドと依存するパッケージを確認します。Reactはバージョン18.2を利用しています。


{
  "private": true,
  "sideEffects": false,
  "scripts": {
    "build": "remix build",
    "dev": "remix dev",
    "start": "remix-serve build"
  },
  "dependencies": {
    "@remix-run/node": "^1.7.0",
    "@remix-run/react": "^1.7.0",
    "@remix-run/serve": "^1.7.0",
    "react": "^18.2.0",
    "react-dom": "^18.2.0"
  },
  "devDependencies": {
    "@remix-run/dev": "^1.7.0",
    "@remix-run/eslint-config": "^1.7.0",
    "eslint": "^8.20.0"
  },
  "engines": {
    "node": ">=14"
  }
}

先ほど確認したブラウザに表示されている初期画面の内容がどのファイルに記述されているのか確認します。

appフォルダのroot.jsxファイルの中でhtmlタグ、bodyタグを確認することができますが開発サーバ起動直後に表示される”Welcome to Remix”は確認できません。Outlet, Meta, Linksなどのタグについては後ほど説明を行います。


import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

export const meta = () => ({
  charset: "utf-8",
  title: "New Remix App",
  viewport: "width=device-width,initial-scale=1",
});

export default function App() {
  return (
    <html lang="en">
      <head>
        <Meta />
        <Links />
      </head>
      <body>
        <Outlet />
        <ScrollRestoration />
        <Scripts />
        <LiveReload />
      </body>
    </html>
  );
}

“Welcome to Remix”はapp/routesフォルダのindex.jsxファイルに記述されています。


export default function Index() {
  return (
    <div style={{ fontFamily: "system-ui, sans-serif", lineHeight: "1.4" }}>
      <h1>Welcome to Remix</h1>
      <ul>
        <li>
          <a
            target="_blank"
            href="https://remix.run/tutorials/blog"
            rel="noreferrer"
          >
            15m Quickstart Blog Tutorial
          </a>
        </li>
        <li>
          <a
            target="_blank"
            href="https://remix.run/tutorials/jokes"
            rel="noreferrer"
          >
            Deep Dive Jokes App Tutorial
          </a>
        </li>
        <li>
          <a target="_blank" href="https://remix.run/docs" rel="noreferrer">
            Remix Docs
          </a>
        </li>
      </ul>
    </div>
  );
}

index.jsxの中身はroot.jsxのOutletタグの部分に挿入されるためroot.jsxファイルのOutletタグを削除すると画面には何も表示されません。

metaタグの設定

meta関数にはcharset, title, viewportが設定されていますがdescriptionを増やしたい場合にはmeta関数の戻り値のオブジェクトにdescriptionプロパティを追加することで行うことができます。


export const meta = () => ({
  charset: "utf-8",
  title: "New Remix App",
  viewport: "width=device-width,initial-scale=1",
  description:"a first remix application"
});

descrptionプロパティを追加後にブラウザでソースを確認するとmetaタグでdescriptionが追加されることが確認できます。

metaタグにdescriptionが追加
metaタグにdescriptionが追加

metaタグについてはMetaタグをroot.jsxファイルのAppコンポーネントから削除すると表示されません。

meta関数の設定はroot.jsxだけでに設定できるものではなく後ほど説明するルーティングファイルに同様の方法で設定を行うことができます。

スタイルシートの設定

CSSのスタイルを適用するためにスタイルシートを利用したい場合はlinks関数を利用することができます。appフォルダの直下にstylesフォルダを作成してglobal.cssを作成します。


h1{
  font-size:3em;
  color:red
}

作成したcssファイルをroot.jsxファイルでimportとしてstylesという名前をつけます。importしたstylesをオブジェクトとして持つ配列を戻すlinks関数で下記のように設定を行います。


import {
  Links,
  LiveReload,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
} from "@remix-run/react";

import styles from "~/styles/global.css";

export const meta = () => ({
  charset: "utf-8",
  title: "New Remix App",
  viewport: "width=device-width,initial-scale=1",
  description:"a first remix application"
});

export function links() {
  return [{ rel: "stylesheet", href: styles }];
}
//略

設定したlinks関数の設定はLinkタグの箇所で設定が行われるためブラウザで確認するとglobal.csssの設定が行われていることが確認できます。

スタイルシートによるCSSを適用
スタイルシートによるCSSを適用

Linkタグをroot.jsxから削除するとスタイルの設定は行われません。

動作確認できたらlinks関数を削除します。

ここまでの説明でroot.jsxファイルに記述されてたMetaコンポーネント、Outletコンポーネント、Linkコンポーネントの役割を理解することができました。

ルーティング

アプリケーションは1つのページではなく複数のページから構成されています。ページ間を移動するためにルーティングが必要となります。RemixのルーティングはReact Router v6が利用されています。

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

Remixではファイルベースルーティングでルーティングを設定することができるのでroutesフォルダにファイルを作成するだけで作成したファイル名を元に自動でルーティングが設定されます。

routesフォルダの下にposts.jsxファイルを作成します。


export default function Post(){
  return (
    <h1>Post Page</h1>
  )
}

ブラウザからhttp://localhost:3000/postsにアクセスすると下記の画面が表示されます。ファイルを作成するだけで簡単にアプリケーションにページを追加することができます。

ルーティングの設定
ルーティングの設定

404ページ

routesフォルダにposts.jsxページを作成するだけで自動でルーティングが設定されページを表示することができました。あるURLにアクセスした場合にそのURLに対応するファイル名を持つファイルがroutesフォルダに存在しない場合には画面上には”404 Not Found”が表示されます。ここでは/aboutにアクセスしますがabout.jsxファイルが存在しないので”404 Not Found”が表示されます。

Not Found 404ページの表示
Not Found 404ページの表示

Nested Routeの設定

routesフォルダの下に先ほど作成したposts.jsxファイルと同じ名前のフォルダpostsを作成します。作成したpostsの下にcreate.jsxファイルを作成して以下を記述します。


export default function CreatePostPage(){
  return (<h2>Create Page</h2>)
}

http://localhosts/posts/createにアクセスすると/postsにアクセスした場合に表示された内容が表示されposts/create.jsxファイルに記述した内容は表示されません。

/posts/createへのアクセス
/posts/createへのアクセス

/posts/create.jsxファイルに記述した内容を表示するためにはposts.jsxファイルでOutletコンポーネントをimportしてコードに配置する必要があります。


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

export default function Post() {
  return (
    <>
      <h1>Post Page</h1>
      <Outlet />
    </>
  );
}

Outletタグを追加することで/posts/createにアクセスすると/posts/create.jsxファイルで記述した内容がOutletタグをおいた場所に表示されます。

Outletタグを設定した場合
Outletタグを設定した場合

文字列”Post Page”はposts.jsx、文字列”Create Page”は/posts/create.jsxファイルの内容が表示されます。これがNested Routeと呼ばれる理由です。posts.jsxが親ルート, create.jsxが子ルートと呼ばれます。

さらに/postsフォルダの中にindex.jsxファイルを作成して以下を記述します。


export default function IndexPostPage() {
  return <h2>Index Page</h2>;
}

http://localhosts:3000/postsにアクセスするとindex.jsxファイルに記述した内容がposts.jsxファイルのOutletタグの箇所に表示されます。index.jsxファイルはindexルートと呼ばれます。通常のWEBサーバでもindex.htmlファイルの場合は/posts/index.htmlとindex.htmlを省略しても/posts/でアクセスできるのと同様に/posts/でアクセスするとindex.jsxファイルがデフォルトとして親ルートのOutlet部分に表示されます。

index.jsxファイルをpostsフォルダに作成した場合
index.jsxファイルをpostsフォルダに作成した場合

ダイナミックルーティング

/posts/1、/posts/first-blog-postのように/posts/の後ろに追加した文字列の値によって表示させる内容を変えたい場合にはダイナミックルーティングを利用することができます。URLが動的に変わるためダイナミックルーティングと呼ばれます。

/postsフォルダの下にファイル名が$で始まる$postId.jsxファイルを作成します。postIdという名前は任意ですが後ほどパラメータとしてこの値を取得する際にpostIdという名前を利用します。


export default function PostDetailPage() {
  return <h2>Post Detail Page</h2>;
}

$posetId.jsxファイルを作成後ブラウザから/posts/1, /posts/2にアクセスを行います。/posts/以降にどのような文字列を入れても$postId.jsxファイルに記述した内容が画面に表示されます。

/posts/の後ろのidを任意の文字列設定
/posts/の後ろのidを任意の文字列設定
/posts/AAA/BBBのように/posts/AAAの文字列の後ろに再度”/”を入れた場合は異なるルーティングとして判断されます。

idの値によって表示する内容を変更するためにはidの値を取得する必要があります。idの取得にはuseParams Hookを利用することができます。


import { useParams } from '@remix-run/react';

export default function PostDetailPage() {
  const params = useParams();
  console.log(params);
  return <h2>Post Detail Page</h2>;
}

ブラウザから/posts/1にアクセスを行うと開発サーバの起動(npm run dev)を実行したターミナルとブラウザのデベロッパーツールのコンソールに{postId: ‘1’}が表示されます。値が含まれているプロパティの名前postIdは作成したファイル名から$を除いた文字列になっています。

ブラウザ上にidを表示するためにparams.postIdを利用することができます。


import { useParams } from '@remix-run/react';

export default function PostDetailPage() {
  const params = useParams();
  return <h2>Post Detail Page {params.postId}</h2>;
}

/posts/100にアクセスした場合はブラウザ上に100が表示されます。/posts/の後ろに設定する文字列によって動的にページの内容を変更できるようになりました。

idをブラウザ上に表示
idをブラウザ上に表示

リンクの設定

トップページ/(ルート)から/postsへのリンクを設定するためにroutes/index.jsxを更新します。aタグを利用してリンクの設定を行っています。


export default function Index() {
  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <h1>Welcome to Remix</h1>
      <ul>
        <li>
          <a href="/posts/">記事一覧</a>
        </li>
      </ul>
    </div>
  );
}

ブラウザに表示されるリンクをクリックすると/posts/ページに移動することができますが移動する際にページのリロードが行われます。

アプリケーション内部のページ間でリンクを設定する場合はLinkコンポーネントを利用することができます。移動先の設定にはLinkコンポーネントのto propsを利用します。


import { Link } from '@remix-run/react';

export default function Index() {
  return (
    <div style={{ fontFamily: 'system-ui, sans-serif', lineHeight: '1.4' }}>
      <h1>Welcome to Remix</h1>
      <ul>
        <li>
          <Link to="/posts">記事一覧</Link>
        </li>
      </ul>
    </div>
  );
}

aタグを利用してリンクを設定した場合とは異なり、Linkコンポーネントを利用した場合は移動時にページのリロードは行われずスムーズにページ移動を行うことができます。

Data Loading

ページ上に表示されるデータは通常データベースまたは外部のサービスを利用して取得します。ここではブラウザ上に表示させるデータの取得方法について確認していきます。

loader関数

routesフォルダの下に作成したルーティングファイルではloader関数を定義することでページにアクセスした際にloader関数が自動で実行されサーバ上でデータ取得の処理を行うことができます。loader関数はbackend APIと同様の役割を持っていると考えることができます。後ほど確認しますがデータベースからデータを取得する際はloader関数の中で実行します。サーバ上で取得したデータをブラウザに渡することでブラウザ上に表示することができます。どのように渡したデータを受け取るかについても後ほど確認します。

/posts/にアクセスした際に記事一覧が表示できるよう設定を行っていくためpostsフォルダのindex.jsxファイルにloader関数を設定します。loader関数ではResponseオブジェクトを戻す必要がありサーバ上でのみ実行されます。Reponseオブジェクトで戻すデータはJSONデータなのでresponseヘッダーには”application/json”を設定しています。本来データを保管するためのデータベースの準備ができていないのでブラウザに渡すデータをposts変数に設定しています。


export async function loader() {
  const posts = [
    {
      id: 1,
      title: 'title A',
      body: 'content A',
    },
    {
      id: 2,
      title: 'title B',
      body: 'content B',
    },
  ];

  const body = JSON.stringify({ posts });
  return new Response(body, {
    headers: {
      'Content-Type': 'application/json',
    },
  });
}

export default function IndexPostPage() {
  return <h2>Index Page</h2>;
}

loader関数が実行されているか確認するためにトップページのリンクを利用して/postsに移動します。/postsに移動するとブラウザからGETリクエストが行われ、デベロッパーツールのnetworkタブにはloader関数が実行することで戻されるデータを確認することができます。

loader関数から戻されるJSONデータ
loader関数から戻されるJSONデータ

loader関数内ではjsonヘルパー関数を利用してResponseオブジェクトの処理を書き換えることができます。


export async function loader() {
  const posts = [
    {
      id: 1,
      title: 'title A',
      body: 'content A',
    },
    {
      id: 2,
      title: 'title B',
      body: 'content B',
    },
  ];

  return json({posts})
}

今後はResponseではなくjsonヘルパーを利用します。

loader関数を設定してデータをreturnしただけではページコンポーネントに取得したデータを表示することはできません。サーバから取得したデータを利用するためにuseLoaderData Hookを利用します。useLoaderDataでは受け取ったJSONデータをパースしてくれます。

コンポーネントにデータが渡されたか確認にするためにパースしたデータをconsole.logを利用して確認します。


export async function loader() {
  const posts = [
    {
      id: 1,
      title: 'title A',
      body: 'content A',
    },
    {
      id: 2,
      title: 'title B',
      body: 'content B',
    },
  ];

  return json({posts})
}

export default function IndexPostPage() {
  const data = useLoaderData();
  console.log(data);
  return <h2>Index Page</h2>;
}

開発サーバを起動したコンソールには取得したデータの内容が表示されるだけではなくブラウザのデベロッパーツールのコンソールにも表示されます。サーバ上でもブラウザ上でも処理が行われていることがわかります。


[
  { id: 1, title: 'title A', body: 'content A' },
  { id: 2, title: 'title B', body: 'content B' }
]

useLoaderData Hookを介して受け取ったデータはmap関数を利用して展開しブラウザ上に表示することができます。


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

export async function loader() {
  const posts = [
    {
      id: 1,
      title: 'title A',
      body: 'content A',
    },
    {
      id: 2,
      title: 'title B',
      body: 'content B',
    },
  ];

  return json({ posts });
}

export default function IndexPostPage() {
  const { posts } = useLoaderData();
  return (
    <>
      <h2>記事一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}

loader関数とuseLoaderData Hookを利用することでデータを表示することができました。

loader関数で戻されたデータを表示
loader関数で戻されたデータを表示

外部リソースからのデータ取得

先ほどの設定ではloader関数に直接データを記述していたのでここでは外部リソースから取得したデータを表示できるように設定を行います。外部リソースには無料で利用できるJSONPlaceHolderを利用します。https://jsonplaceholder.typicode.com/postsにアクセスすると100件分のpostデータを取得することができます。

loader関数の中ではJSONPladeholderからのデータ取得はfetch関数を利用して行います。


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

export async function loader() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const data = await response.json();

  return json({ posts: data });
}

export default function IndexPostPage() {
  const { posts } = useLoaderData();
  return (
    <>
      <h2>記事一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </>
  );
}

ブラウザで確認するとJSONPlaceHolderから取得したデータが表示されます。

JSONPlaceHolderから取得したデータを表示
JSONPlaceHolderから取得したデータを表示

記事一覧にリンクを貼り、リンクをクリックすると各記事の内容が表示されるようにLinkタグの設定を行います。


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

export async function loader() {
  const response = await fetch('https://jsonplaceholder.typicode.com/posts');
  const data = await response.json();

  return json({ posts: data });
}

export default function IndexPostPage() {
  const { posts } = useLoaderData();
  return (
    <>
      <h2>記事一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

記事一覧にはリンクが貼られます。

リンクの設定
リンクの設定

クリックすると記事の中身が確認できるように$postId.jsxファイルを更新します。URLに含まれるpostIdを利用してJSONPlaceHolderからデータを取得するためloader関数を利用します。postIdはloader関数の引数のparamsから取り出すことができます。


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

export async function loader({ params }) {
  const response = await fetch(
    `https://jsonplaceholder.typicode.com/posts/${params.postId}`
  );
  const data = await response.json();

  return json({ post: data });
}

export default function PostDetailPage() {
  const { post } = useLoaderData();
  return (
    <>
      <h1>{post.title}</h1>
      <div>{post.body}</div>
    </>
  );
}

$postId.jsxファイルを更新後、リンクをクリックすると記事の内容を確認することができます。外部リソースからデータの取得もloader関数でサーバ上で処理を行い、ブラウザで表示できることがわかりました。

記事の詳細ページ
記事の詳細ページ

データベース

loader関数を利用してサーバ上でデータを取得することができました。次はデータを取得するだけではなくデータを作成/更新/削除を行うMutationの動作確認のためデータベースを準備します。

本文書ではデータベースにはSQLiteデータベースを利用しますがSQLiteにはPrismaを利用して接続します。Prismaを利用していますが手順通りに進めれば難しい設定はありません。接続するデータベースを意識することなくオブジェクトのメソッドを利用して操作することができる便利なツールです。

Prismaの設定

Prismaを利用するためにはnpmコマンドを利用してPrismをインストールを行う必要があります。


 % npm install prisma --save-dev
 

Prisma用の設定ファイルを作成するためにnpx prisma initコマンドを実行します。実行するとプロジェクトフォルダにはprismaフォルダと.envファイルが作成されます。prismaフォルダにはPrismaの設定ファイルであるschema.prismaファイルが作成されています。.envファイルはデータベースに接続するために必要となる環境変数を設定するために利用します。SQLiteデータベースを利用するのでオプション–datasource-providerにsqliteを設定します。オプションを指定しない場合はデータベースにはpostgresqlが設定されています。


 % npx prisma init --datasource-provider sqlite

✔ Your Prisma schema was created at prisma/schema.prisma
  You can now open it in your favorite editor.

warn You already have a .gitignore file. Don't forget to add `.env` in it to not commit any private information.

Next steps:
1. Set the DATABASE_URL in the .env file to point to your existing database. If your database has no tables yet, read https://pris.ly/d/getting-started
2. Run prisma db pull to turn your database schema into a Prisma schema.
3. Run prisma generate to generate the Prisma Client. You can then start querying your database.

More information in our documentation:
https://pris.ly/d/getting-started
 

schema.prismaファイルでは–datasource-providerを指定した実行した場合はSQLiteデータベースに関する設定は完了しているのでモデルの設定を行います。モデルにはpostテーブルを作成するためのスキーマ(テーブルの構成情報)を記述します。


// This is your Prisma schema file,
// learn more about it in the docs: https://pris.ly/d/prisma-schema

generator client {
  provider = "prisma-client-js"
}

datasource db {
  provider = "sqlite"
  url      = env("DATABASE_URL")
}

model Post {
  id       Int    @id @default(autoincrement())
  title    String
  body String
  createdAt DateTime @default(now())
  updatedAt DateTime @updatedAt
}

設定が完了したらデータベースにpostテーブルを作成するためにnpx prisma db pushコマンドを実行します。コマンドを実行すると.envファイルのDATABASE_URLで指定した場所にSQLiteのデータベースファイルが作成されます。schema.prismaファイルのmodel以外の設定を変更していない場合にはprismaフォルダにdev.dbファイルが作成されます。


 % npx prisma db push

テーブルに2件のデフォルトデータを挿入するためにseedの機能を利用します。Prismaにおけるテーブルへのデータの挿入はcreateメソッドを利用していることがわかります。createメソッドはPOSTリクエストからデータを作成する際にも利用します。


const { PrismaClient } = require('@prisma/client');

const prisma = new PrismaClient();

async function seed() {
  await prisma.post.create({
    data: {
      title: 'My first post',
      body: 'Hello, world!',
    },
  });

  await prisma.post.create({
    data: {
      title: 'My second post',
      body: 'Hello, world!',
    },
  });

  console.log(`Database has been seeded. 🌱`);
}

seed()
  .catch((e) => {
    console.error(e);
    process.exit(1);
  })
  .finally(async () => {
    await prisma.$disconnect();
  });

seedを実行するためにはpackage.jsonファイルにseedの設定を追加する必要があります。設定しない場合はseedのコマンドを実行するとエラーが発生します。


{
  "private": true,
  "sideEffects": false,
  "scripts": {
    "build": "remix build",
    "dev": "remix dev",
    "start": "remix-serve build"
  },
  "prisma": {
    "seed": "node prisma/seed.js"
  },
  //略

package.jsonファイルを更新後にnpx prisma db seedコマンドを実行します。postテーブルにはデータが挿入されました。


% npx prisma db seed
Environment variables loaded from .env
Running seed command `node prisma/seed.js` ...
Database has been seeded. 🌱

🌱  The seed command has been executed.
 

データベースに接続するためにPrismClientを利用しますが開発中にPrismaClientのインスタンスがいくつも起動しないようにappフォルダにdb.server.jsファイルを作成します。


import { PrismaClient } from '@prisma/client';

let prisma;

if (process.env.NODE_ENV === 'production') {
  prisma = new PrismaClient();
} else {
  if (!global.prisma) {
    global.prisma = new PrismaClient();
  }
  prisma = global.prisma;
}

export default prisma;
 

ここまででPrismaの設定は完了です。

postテーブルからのデータ取得

Prismaを利用してSQLiteデータベースのpostテーブルにデータの挿入が完了したのでPrismaを経由してpostデータの取得を行います。

loader関数の中でfindmanyメソッドを利用することでpostテーブルから保存されているすべてのデータを取得することができます。


import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import { Link } from '@remix-run/react';
import prisma from '../../db.server';

export async function loader() {
  const posts = await prisma.post.findMany();
  return json({ posts });
}

export default function IndexPostPage() {
  const { posts } = useLoaderData();
  return (
    <>
      <h2>記事一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
    </>
  );
}

ブラウザで確認するとテーブルから取得した記事のタイトルが表示されます。

テーブルから取得したデータの表示
テーブルから取得したデータの表示

リンクをクリックした先でもPrisma経由でデータペースからpostデータを取得できるようにposts/$postId.jsxも更新します。ここではidを利用してpostデータを取得するためにfindUniqueメソッドを利用しています。


import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';
import prisma from '../../db.server';

export async function loader({ params }) {
  const id = Number(params.postId);
  const post = await prisma.post.findUnique({ where: { id } });

  return json({ post });
}

export default function PostDetailPage() {
  const { post } = useLoaderData();
  return (
    <>
      <h1>{post.title}</h1>
      <div>{post.body}</div>
    </>
  );
}

Mutation

データベーステーブルからのデータを取得することができるようになったので RemixのFormコンポーネントとactionを利用したデータに対するMutation(作成、更新、削除)について確認を行なっていきます。

サーバ側でデータを取得する場合にはloader関数を利用していきました。データの作成/更新/削除にはaction関数を利用します。

フォームの作成

フォームの作成にはFormコンポーネントを利用します。posts/index.jsxファイルの記事一覧の下にFormコンポーネントを利用してフォームを追加します。Formタグにはmethodを追加しPOSTを設定します。通常のReactフレームワークで利用するようなonSubmitイベントやonChangeイベントは見つからずPHPでフォームを作成した時の形と同じ形をしていることがわかります。


import { json } from '@remix-run/node';
import { useLoaderData, Form } from '@remix-run/react';
import { Link } from '@remix-run/react';
import prisma from '../../db.server';

export async function loader() {
  const posts = await prisma.post.findMany();
  return json({ posts });
}

export default function IndexPostPage() {
  const { posts } = useLoaderData();
  return (
    <>
      <h2>記事一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
      <div>
        <Form method="post">
          <div>
            <label htmlFor="title">Title:</label>
            <input type="text" name="title" id="title" />
          </div>
          <div>
            <label htmlFor="body">Body:</label>
            <input type="text" name="body" id="body" />
          </div>
          <button type="submit">作成</button>
        </Form>
      </div>
    </>
  );
}

ブラウザで確認するとtitleとbodyの入力フィールドを持つフォームが表示されます。

表示されたフォームに入力
表示されたフォームに入力

ブラウザ側のマークアップをデベロッパーツールの要素で確認しておきます。Formコンポーネントではactionの指定を行なっていませんがformタグのaction属性には/posts?indexが設定されていることがわかります。enctypeも自動で設定されています。

Formコンポーネントの要素を確認
Formコンポーネントの要素を確認

フォームのTitleとBodyに文字列を入力して”作成”ボタンをクリックしてください。

ページ上には”405 Method Not Allowed”が表示されます。

Method Not Allowedメッセージ
Method Not Allowedメッセージ

デベロッパーツールのネットワークタブを確認するとformタグに設定されていたactionの設定値である/posts?indexに対してPOSTリクエストが送信されていますがステータスコード405が戻されていることが確認できます。

ネットワークタブで処理を確認
ネットワークタブで処理を確認

POSTリクエストを受け取り、処理をするためにはaction関数を設定する必要があります。

action関数の設定

ルーティングファイルindex.jsxにaction関数を追加することでPOSTリクエストで送信されてくるrequestオブジェクトを取得することができます。requestオブジェクトはformDataメソッドを持っているのでformDataメソッドからrequest bodyに含まれているtitleとbodyを取得します。

requestオブジェクトが持つプロパティやメソッドはhttps://developer.mozilla.org/en-US/docs/Web/API/Requestで確認することができます。


export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');
  console.log(title, body);
  return null;
}

action関数はサーバ上でのみ実行されるので”npm run dev”コマンドを実行しているコンソールにtitleとbodyの値が表示されます。action関数はnullまたはvalueを必ず戻す必要があるのでここでは一時的にnullを設定していています。何もreturnしていない場合はエラーメッセージが表示されます。

titleとbodyがaction関数内で取得できることが確認できたのでtitleとbodyを利用してpostテーブルに新規のデータを追加します。

prism.postオブジェクトのcreateメソッドを利用します。


export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');
  return await prisma.post.create({ data: { title, body } });
}

設定が完了したらTitleとBodyフィールドに入力を行い、”作成”ボタンをクリックします。

データを追加後、即座に反映
データを追加後、即座に反映

データがテーブルに登録された後、登録されたデータは即座にブラウザ上に反映されます。ネットワークタブを確認するとPOSTリクエストに成功した後にGETリクエストが実行され追加したデータを含めた記事情報を取得していることが確認できます。データ登録後に再取得といったコードを記述する必要がなくRemixではデータの再取得は自動で行ってくれます。

POSTリクエストの後にGETリエクスト
POSTリクエストの後にGETリエクスト

Formの処理はJavaScriptを停止していても実行することができます。その場合はPOSTリクエストを送信後ブラウザのリロードが実行され追加した記事を含めたページの内容が戻されるためブラウザ上には記事が追加された記事一覧が表示されます。

JavaScriptを停止しても動作するのでFormタグからformタグに変更しても処理は動作します。データ送信後はブラウザのリロードが行われます。


<form method="post" action="/posts?index">
  <div>
    <label htmlFor="title">Title:</label>
    <input type="text" name="title" id="title" />
  </div>
  <div>
    <label htmlFor="body">Body:</label>
    <input type="text" name="body" id="body" />
  </div>
  <button type="submit">作成</button>
</form>

useSubmit Hook

Formコンポーネントを設定することで自動でonSubmitメソッドが実行されPOSTリクエストが送信されます。useSubmit Hookを利用することでonSubmitを明示的に設定して処理を行なうことも可能です。

FormタグにonSubmitイベントを追加し、handleSubmit関数を設定します。handleSubmit関数の中ではimportしたuseSubmit Hookの戻り値のsubmit関数の引数にevent.currentTargetでform全体の要素を設定しています。動作はuseSubmitと利用する前と同じです。


export default function IndexPostPage() {
  const { posts } = useLoaderData();
  const submit = useSubmit();

  const handleSubmit = (e) => {
    submit(e.currentTarget);
  };

  return (
    <>
      <h2>記事一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
      <div>
        <Form method="post" onSubmit={handleSubmit}>
          <div>
            <label htmlFor="title">Title:</label>
            <input type="text" name="title" id="title" />
          </div>
          <div>
            <label htmlFor="body">Body:</label>
            <input type="text" name="body" id="body" />
          </div>
          <button type="submit">作成</button>
        </Form>
      </div>
    </>
  );
}

Formの属性

action

Formタグでactionを設定していませんでしたが設定した場合の動作も確認しておきます。下記ではactionでは/posts/createを指定しています。


<Form method="post" action="/posts/create">
  <div>
    <label htmlFor="title">Title:</label>
    <input type="text" name="title" id="title" />
  </div>
  <div>
    <label htmlFor="body">Body:</label>
    <input type="text" name="body" id="body" />
  </div>
  <button type="submit">作成</button>
</Form>

/posts/createをactionに指定するとpostsフォルダのcreate.jsxファイルに対してPOSTリクエストが送信されるのでcreate.jsxにaction関数を追加します。create.jsxではデータを作成した後にredirectを利用して/posts/にリダイレクトされます。


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

export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');
  const post = await prisma.post.create({ data: { title, body } });
  return redirect('/posts/');
}

export default function CreatePostPage() {
  return <h2>Create Page</h2>;
}

Title, Bodyフィールドに文字列を入力した後に”作成”ボタンをクリックすると/posts/createにPOSTリクエストが送信されcreate.jsxでデータに登録された後にリダイレクトされた/postsページにはデータが追加された記事一覧が表示されます。

actionは省略することができ、省略した場合はフォームを記述したページにPOSTリエクストが送信されます。actionにURLを指定した場合はそのURLにPOSTリクエストが送信されそのURLに対応するルーティングファイルのactionが実行されることがわかりました。

action以外でFormタグに設定可能な属性を確認しておきます。

encType

action以外のencTypeも変更を行うことができます。デフォルトでは”application/x-www-form-urlencoded”ですが、ファイルをアップロードしたい場合には”multipart/form-data”に変更することができます。

reloadDocument

reloadDocumentをFormタグに設定するとJavaScriptではなくブラウザのフォーム機能でsubmitが行われます。そのためsubmitを行なうとページのリロードが行われます。


<Form method="POST" reloadDocument>

replace

フォームを利用してリスエストを送信すると送信する度にhistory stackにそのページのエントリーが追加されていきます。同じページで3回連続でリクエストを送信した後にブラウザの戻るボタンを4回クリックするまで同じページが表示されることになります。replaceを追加することでフォームを利用してデータを送信してもhistory stackにエントリーが追加することはないのでブラウザの戻るボタンをクリックするとそのページに移動する前に閲覧したページに戻ることができます。


<Form replace />

バリデーションとuseActionData Hook

POSTリクエストで送信されているデータのバリデーション方法とエラーの表示方法について確認します。

バリデーションはブラウザ側でも行うことができますがここではaction関数内でバリデーションを行っていきます。

下記のバリデーションではtitleの値がstringがどうかのチェックと文字列の長さが0かどうかチェックしています。バリデーションに失敗した場合にはerrorsオブジェクトのtitleプロパティにエラーの内容を設定し、ステータスコード400を戻します。


export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');

  if (typeof title !== 'string' || title.length === 0) {
    return json(
      { errors: { title: 'Title is required', body: null } },
      { status: 400 }
    );
  }

  if (typeof body !== 'string' || body.length === 0) {
    return json(
      { errors: { title: null, body: 'Body is required' } },
      { status: 400 }
    );
  }
  return await prisma.post.create({ data: { title, body } });
}

ブラウザ側ではactiion関数で戻されたエラーの内容(errors)を表示するためにuseActionData Hookを利用します。useActionDataの中に戻されたエラーが入っているのでエラーが入っているかチェックを行いエラーがある場合にはブラウザ上に表示させています。


import { json } from '@remix-run/node';
import { useLoaderData, Form, useActionData } from '@remix-run/react';
import { Link } from '@remix-run/react';
import prisma from '../../db.server';

export async function loader() {
  const posts = await prisma.post.findMany();
  return json({ posts });
}

export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');

  if (typeof title !== 'string' || title.length === 0) {
    return json(
      { errors: { title: 'Title is required', body: null } },
      { status: 400 }
    );
  }

  if (typeof body !== 'string' || body.length === 0) {
    return json(
      { errors: { title: null, body: 'Body is required' } },
      { status: 400 }
    );
  }
  return await prisma.post.create({ data: { title, body } });
}

export default function IndexPostPage() {
  const { posts } = useLoaderData();
  const actionData = useActionData();
  return (
    <>
      <h2>記事一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
      <div>
        <Form method="post">
          <div>
            <label htmlFor="title">Title:</label>
            <input type="text" name="title" id="title" />
            {actionData?.errors?.title && <div>{actionData.errors.title}</div>}
          </div>
          <div>
            <label htmlFor="body">Body:</label>
            <input type="text" name="body" id="body" />
            {actionData?.errors?.body && <div>{actionData.errors.body}</div>}
          </div>
          <button>作成</button>
        </Form>
      </div>
    </>
  );
}

バリデーションの方法は一つではないので下記のような方法でバリデーションを行うこともできます。


export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');

  const errors = {
    title: title ? null : 'Title is required',
    body: body ? null : 'Body is required',
  };
  const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);

  if (hasErrors) {
    return json({ errors }, { status: 400 });
  }

  return await prisma.post.create({ data: { title, body } });
}
エラーが発生しているかどうかに関わらずPOSTリクエストを送信後はGETリクエストが送信されデータが戻されます。

useTransition Hook

useTransition Hookを利用することでページ遷移についての情報を取得することができます。useTransitionによって取得できる情報をフォームで活用することでリクエストによりデータ送信が行われている間にはボタンを無効にしたり、ローディングを表示させたりすることがでユーザが何の処理を行なっているのかを把握することができます。

まずはuseTransitionではどのような情報が取得できるのかフォームを利用して確認します。


import { json } from '@remix-run/node';
import {
  useLoaderData,
  Form,
  useActionData,
  useTransition,
} from '@remix-run/react';
import { Link } from '@remix-run/react';
import prisma from '../../db.server';
//略
export default function IndexPostPage() {
  const { posts } = useLoaderData();
  const actionData = useActionData();
  const transition = useTransition();
  console.log(transition);
  return (
    <>
      <h2>記事一覧</h2>
//略
  );
}

POSTリエクストに失敗していますが下記のように取得できる情報が変化しています。useTransition Hookから戻されるオブジェクトには4つのプロパティ(state, submission, location ,type)があることもわかります。

useTransitionの中身
useTransitionの中身

statusの流れは”idle”から始まり、”submitting”に変わり”loading”を経由して最後は再度”idle”に戻っています。statusを値を利用することで現在の状態を確認することができるので動的に表示させるUIを変えることも可能です。

フォーム送信に関わる一連の処理が行われているかどうかはsubmissionに値が入っているかどうかで判断することができるでここではsubmissionを利用してボタンの無効化とボタンのテキストの動的な変更設定を行います。その前にsubmissionにどのような値が入っているかも確認しておきます。

submissionの中身
submissionの中身

submissionにはactionやencTypeやformDataなどが含まれています。statusがloaddingでもsubmittingでもtype以外は入っている情報は同じです。

submissionに値があるかどうかでtrueとfalseを取れるようにBooleanを利用します。


let submission = Boolean(transition.submission);

submissionがtrueの時はボタンが無効になり、ボタンのテキストは”処理中”が表示されます。


<button type="submit" disabled={submission}>
  {submission ? '処理中' : '作成'}
</button>

//略
export default function IndexPostPage() {
  const { posts } = useLoaderData();
  const actionData = useActionData();
  const transition = useTransition();
  let submission = Boolean(transition.submission);

  return (
    <>
      <h2>記事一覧</h2>
      <ul>
        {posts.map((post) => (
          <li key={post.id}>
            <Link to={`/posts/${post.id}`}>{post.title}</Link>
          </li>
        ))}
      </ul>
      <div>
        <Form method="post">
          <div>
            <label htmlFor="title">Title:</label>
            <input type="text" name="title" id="title" />
            {actionData?.errors?.title && <div>{actionData.errors.title}</div>}
          </div>
          <div>
            <label htmlFor="body">Body:</label>
            <input type="text" name="body" id="body" />
            {actionData?.errors?.body && <div>{actionData.errors.body}</div>}
          </div>
          <button type="submit" disabled={submission}>
            {submission ? '処理中' : '作成'}
          </button>
        </Form>
      </div>
    </>
  );
}

“作成”ボタンをクリックして処理が完了するまでボタンは無効となり、ボタンのテキストには”処理中”が表示されます。

フォーム処理中
フォーム処理中

useTransitionを利用することで動的に表示させる内容を変更できることがわかりました。

削除機能の追加

記事一覧からデータベースに保存されているデータを削除できるように設定を行なっていきます。

ボタンをクリックするとクリックした記事が削除されるようにボタン要素を追加します。ボタンを追加しただけなのでボタンをクリックしても何も変化はありません。


<h2>記事一覧</h2>
<ul>
  {posts.map((post) => (
    <li key={post.id}>
      <Link to={`/posts/${post.id}`}>{post.title}</Link>
      <button>X</button>
    </li>
  ))}
</ul>

通常のReactであればbutton要素を追加してonClickイベントを設定する等が考えられますがここではFormコンポーネントを利用します。


<h2>記事一覧</h2>
<ul>
  {posts.map((post) => (
    <li key={post.id}>
      <div style={{ display: 'flex' }}>
        <Link to={`/posts/${post.id}`}>{post.title}</Link>
        <Form method="post">
          <button type="submit">X</button>
        </Form>
      </div>
    </li>
  ))}
</ul>

XボタンをクリックするとそのページのURLにP0STリクエストが送信されるためaction関数が実行され、記事の作成フォームのバリデーションによるエラーメッセージが表示されます。

Xボタンをクリックした場合
Xボタンをクリックした場合

Formタグにactionをつけて別のルーティング(URL)を設定しそのルートファイルにaction関数を設定することも可能ですが、複数のフォームが存在するUIを1つのページ上(1つのaction)で対応できるように設定していきます。そのためには複数のフォームが存在する場合にはどのフォームから送信されてきたリクエストなのかを識別する情報が必要になります。

識別できる情報を送信するリクエストに含めるためbutton要素にname属性を設定してvalueに処理の名前を設定します。ここではnameの値を”action”にして処理が削除なので値は”delete”にしています。nameもvalueも任意の名前をつけることができます。


<Form method="post">
  <button type="submit" name="action" value="delete">
    X
  </button>
</Form>

action関数内でbutton要素に設定したactionの値が取得できるか確認します。


export async function action({ request }) {
  const formData = await request.formData();
  const title = formData.get('title');
  const body = formData.get('body');
  const action = formData.get('action');
  console.log(action);
//略

action関数はサーバ側で実行されるためボタンをクリックすると”npm run dev”コマンドを実行しているコンソールに”delete”が表示されます。

記事の作成フォームのボタンにもname属性とvalue属性を設定します。name属性の値はactionですが、valueの値はcreateに設定しています。


<button
  type="submit"
  disabled={submission}
  name="action"
  value="create"
>
  {submission ? '処理中' : '作成'}
</button>

設定後、今後は”作成”ボタンをクリックするとコンソールには”create”が表示されます。ここまでの設定によりどのフォームから送信されてきたリクエストなのか識別できるようになりました。さらにフォームを追加した場合はcreate, delete以外の値を設定することで対応することができます。

action関数の中ではactionの値とswitch構文を利用して分岐を行います。下記のコードによりactionが”create”の場合にデータ作成の処理、”delete”の場合はデータ削除の処理が行えるようになります。


export async function action({ request }) {
  const formData = await request.formData();

  switch (formData.get('action')) {
    case 'create': {
      const title = formData.get('title');
      const body = formData.get('body');

      const errors = {
        title: title ? null : 'Title is required',
        body: body ? null : 'Body is required',
      };
      const hasErrors = Object.values(errors).some(
        (errorMessage) => errorMessage
      );

      if (hasErrors) {
        return json({ errors }, { status: 400 });
      }

      return await prisma.post.create({ data: { title, body } });
    }

    case 'delete': {
      return null;
    }

    default: {
      throw new Error('不明なactionの値が設定されています');
    }
  }
}
分岐にswitchを利用していますがif文の分岐でも構いません。

削除処理の中身を設定していなかったので処理の追加を行います。削除するためにはどのデータを削除するのか識別する値が必要となるのでテーブルの中で一意の値であるidを利用します。idを利用するためにFormコンポーネントでidを設定する必要があります。input要素のtypeをhiddenに設定してvalueにpost.idを設定しています。


  <Form method="post">
    <input name="id" type="hidden" value={post.id} />
    <button type="submit" name="action" value="delete">
      X
    </button>
  </Form>

action関数ではformData.get(‘id’)でidの値を取得することができるのでidを利用してpostテーブルからデータを削除することができるようになります。


case 'delete': {
  const id = Number(formData.get('id'));

  return await prisma.post.delete({
    where: { id },
  });
}

Xボタンをクリックするとクリックした行を削除できるようになりました。削除処理が行われると自動でGETリクエストが送信されるため削除は自動で記事一覧に反映されます。

記事データを作成するためにuseTransitionを利用してフォームの送信/処理中はボタンのテキストの変更とボタンの無効化を行いました。記事データの作成と削除どちららの処理でも1つのuseTranstitionを利用するため削除ボタンをクリックしても”作成”ボタンのテキストとボタンの無効化が行われます。そのため削除ボタンの影響を受けないようにするためにはどちらのフォームの処理か識別する必要があります。useTransitionの中のsubmissionにはformDataが含まれていたことを思い出してください。formDataにアクセスできるということはaction関数で利用したactionの値(create or delete)にもアクセスすることができます。submissionの変数をformDataのactionの値も含めた処理に変更することで”作成”ボタンをクリックした場合のみボタンのテキストの変更と無効化を行なうようにすることができます。


let submission = Boolean(
  transition.submission &&
    transition.submission?.formData.get('action') === 'create'
);

更新機能の追加

データの作成、削除の方法を確認したので次は更新方法を確認します。

記事一覧のタイトルのリンクをクリックすると詳細画面が表されるように設定されていますが、詳細画面でpostデータの更新が行えるように変更します。

コードはindex.jsxでのデータを作成する時とほとんど同じですが、各フォールドのinput要素にpostIdを利用してデータベーステーブルから取得したpostデータをdefaultValueで初期値として設定しています。データの更新なのでprisma.postのupdateメソッドを利用しています。


import { json, redirect } from '@remix-run/node';
import {
  useLoaderData,
  Form,
  useActionData,
  useTransition,
} from '@remix-run/react';
import prisma from '../../db.server';

export async function loader({ params }) {
  const id = Number(params.postId);
  const post = await prisma.post.findUnique({ where: { id } });

  return json({ post });
}

export async function action({ request, params }) {
  const id = Number(params.postId);
  const formData = await request.formData();

  const title = formData.get('title');
  const body = formData.get('body');

  const errors = {
    title: title ? null : 'Title is required',
    body: body ? null : 'Body is required',
  };
  const hasErrors = Object.values(errors).some((errorMessage) => errorMessage);

  if (hasErrors) {
    return json({ errors }, { status: 400 });
  }

  await prisma.post.update({
    where: { id },
    data: { title, body },
  });

  return redirect('/posts/');
}

export default function PostDetailPage() {
  const { post } = useLoaderData();
  const actionData = useActionData();
  const transition = useTransition();
  let submission = Boolean(transition.submission);

  return (
    <Form method="POST">
      <div>
        <label htmlFor="title">Title:</label>
        <input type="text" name="title" id="title" defaultValue={post.title} />
        {actionData?.errors?.title && <div>{actionData.errors.title}</div>}
      </div>
      <div>
        <label htmlFor="body">Body:</label>
        <input type="text" name="body" id="body" defaultValue={post.body} />
        {actionData?.errors?.body && <div>{actionData.errors.body}</div>}
      </div>
      <button type="submit" disabled={submission} name="action" value="create">
        {submission ? '処理中' : '更新'}
      </button>
    </Form>
  );
}

記事一覧からタイトルをクリックすると現在の値が設定された更新フォームが表示されます。

更新ページの表示
更新ページの表示

Titleを更新を行い(updatedの文字列を追加)、”更新”ボタンをクリックすると/postsにリダイレクトされTitleが更新されていることが確認できます。Fromコンポーネントを利用して更新フォームの作成を行うことができました。

更新後にリダイレクトで表示された記事一覧
更新後にリダイレクトで表示された記事一覧

useFetcher Hook

ここまでの設定ではページを移動した際(Linkタグを利用したナビゲーションによる移動)にloader関数が実行されていましたがページの移動ではなくUI上でのユーザとのインタラクションによってloader関数を実行したい場合(サーバからデータを取得)もあります。その場合にuserFetcher Hookを利用することができます。

useFetcher Hookがどのように利用できるか動作確認するためroutesフォルダに新たにuser.jsxとusers.jsxファイルを作成します。作成後は/user, /usersのルーティングが自動で追加されます。

users.jsxファイルにはloader関数のみ記述します。ルーティングファイルは必ずしもdefaultコンポーネントをexportする必要はなくloader関数またはaction関数のみ記述することができます。loader関数の中ではユーザ名が入った配列を戻しています。


import { json } from '@remix-run/node';
export async function loader() {
  const users = ['John', 'Jack', 'Jane'];
  return json({ users });
}

user.jsxファイルではuseFetcher Hookを利用するためimportします。load関数をuseFetcherを利用して実行したい場合にはuserFetcherのloadメソッドを利用します。loadメソッドの引数にはルーティングのパスを設定します。”Get Users”ボタンにはclickイベントが設定されているのでボタンをクリックするとhandleClick関数が実行され、handleClick関数の中でデータの取得が行われます。


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

export default function User() {
  const fetcherUsers = useFetcher();

  const handleClick = () => {
    fetcherUsers.load('/users');
  };

  return (
    <>
      <h1>User Page</h1>
      <button onClick={handleClick}>Get Users</button>
      <ul>
        {fetcherUsers.data?.users.map((user, index) => (
          <li key={index}>{user}</li>
        ))}
      </ul>
    </>
  );
}

useFetcher Hookを利用してloaderから取得したデータはfetcherUsers.dataに保存されます。作成したコードを利用してブラウザで動作確認を行います。

/userページにアクセスした際にはユーザのリストは存在しません。

/usersアクセス直後の画面
/usersアクセス直後の画面

“Get Users”ボタンをクリックするとfetcher.loadメソッドが実行され引数に指定した/usersに対応するusers.jsxファイルのload関数が実行されます。取得したデータはfetcher.dataに保存されるのでmap関数で展開してユーザ一覧が表示されます。

ユーザ名一覧の表示
ユーザ名一覧の表示

ページの移動によるナビゲーションとは別にuseFetcherを利用することでloader関数を実行してデータを取得することができました。

次はuserFetcherのsubmitメソッドを確認します。submitメソッドを実行することでaction関数を実行することができます。

先ほどはloaderメソッドで取得したデータをリスト表示していましたがここでは取得したユーザ情報はselect要素のoptionとして利用しています。”Get Users”ボタンをクリックするとselect要素でユーザが選択できるようになります。


import { json } from '@remix-run/node';
import { useFetcher } from '@remix-run/react';

export async function action({ request }) {
  const formData = await request.formData();
  const username = formData.get('username');
  return json({ username });
}

export default function User() {
  const fetchUsers = useFetcher();
  const fetchUser = useFetcher();

  const handleClick = () => {
    fetchUsers.load('/users');
  };

  const handleChange = (e) => {
    fetchUser.submit(e.target.form);
  };

  return (
    <>
      <h1>User Page</h1>
      <button onClick={handleClick}>Get Users</button>
      <p>
        Selected User:
        {fetchUser.data?.username && <span>{fetchUser.data.username}</span>}
      </p>
      <fetchUser.Form method="post">
        <select name="username" onChange={handleChange}>
          {fetchUsers.data?.users.map((user, index) => (
            <option key={index}>{user}</option>
          ))}
        </select>
      </fetchUser.Form>
    </>
  );
}

fetcherが持つFormでFormタグと同様にmethodを設定することができます。action属性なども設定できますが同じファイル上のaction関数を実行するため指定していません。

selectタグにonChangeイベントを設定し選択を行なうとhandleChange関数が実行されます。handleChange関数ではfetcher.submitによりaction関数が実行され選択したユーザが戻されます。戻されたデータはfetcher.dataに保存されるので取得したデータを画面上に表示しています。

ブラウザで動作確認を行います。

アクセス直後ではselect要素にoptionが設定されていなためユーザ名を選択することはできません。

/userアクセス時の状態
/userアクセス時の状態

“Get Users”ボタンをクリックするとselectで取得したユーザが選択できるようになります。

select要素でユーザを選択することが可能に
select要素でユーザを選択することが可能に

セレクトメニューでユーザ名を選択するとブラウザ上に選択したユーザ名が表示されます。ブラウザ上に表示されるユーザ名はaction関数を利用してサーバからデータを取得して表示しています。

select要素で選択したユーザを表示
select要素で選択したユーザを表示

fetchUser.submitの引数に設定したe.target.formの中身が何なのか気になる人がいるかもしれないので確認しておくとフォーム部分の要素が含まれているHTMLです。

e.target.formの中身
e.target.formの中身

userFetcherにはdata以外にもstate, type, submissionを持っています。useTransitionのようにsubmissionを利用することがactionによるデータ取得中にテキストを表示することできます。ユーザ名を選択してから表示されるまでの間”Selected User:”の文字列の横に”Getting User”の文字列が表示され、取得が完了すると選択ユーザ名が表示されます。


export default function User() {
  const fetchUsers = useFetcher();
  const fetchUser = useFetcher();
  let submission = Boolean(fetchUser.submission);

  const handleClick = () => {
    fetchUsers.load('/users');
  };

  const handleChange = (e) => {
    fetchUser.submit(e.target.form);
  };

  return (
    <>
      <h1>User Page</h1>
      <button onClick={handleClick}>Get Users</button>

      <p>
        Selected User:
        {submission && <span>Getting User</span>}
        {fetchUser.data?.username && <span>{fetchUser.data.username}</span>}
      </p>
      <fetchUser.Form method="post">
        <select name="username" onChange={handleChange}>
          {fetchUsers.data?.users.map((user, index) => (
            <option key={index}>{user}</option>
          ))}
        </select>
      </fetchUser.Form>
    </>
  );
}

ページの移動に伴うナビゲーションの操作とは別にuseFetcher Hookを利用することでユーザの操作を元にloader関数、action関数を実行する方法を確認することができました。

Error handling

loader関数やaction関数内で発生するエラーの処理について確認していきます。

CatchBoundaryの設定

記事一覧のタイトルのリンクをした場合にpostIdを利用しデータベーステーブルへのアクセスが行われデータが存在すれば更新画面が表示されますがテーブルに存在しないデータのidが指定された場合にはApplication Errorの画面が表示されます。

URLにデータ存在しないidが指定された場合
URLにデータ存在しないidが指定された場合

どのような条件でエラーが発生するのかわかっているのでテーブルにデータが存在しない場合にはステータスコード404でReponseオブジェクトをthrowさせます。


export async function loader({ params }) {
  const id = Number(params.postId);
  const post = await prisma.post.findUnique({ where: { id } });

  if (!post) {
    throw new Response('Not Found', {
      status: 404,
      statusText: 'Not Found',
    });
  }
  return json({ post });
}

再度ブラウザで確認すると先ほどのApplication Errorとは異なり”404 Not Found”画面が表示されます。

404 Not Foundエラー
404 Not Foundエラー

CatchBoundaryコンポーネントを設定することでエラーがthrowされた場合にthrowされたエラーをcatchして表示する画面をカスタマイズすることができます。$postId.jsxファイルにCatchBoundary関数を追加します。CatchBoundaryの中では404のステータスコードのエラーをcatchした場合のみカスタマイズした画面が表示されるように設定を行っています。


export function CatchBoundary() {
  const caught = useCatch();
  const params = useParams();
  if (caught.status === 404) {
    return <div>Post id:{params.postId}を持つ記事は存在しません。</div>;
  }
  throw new Error(`ERROR: ${caught.statusText} ${caught.status}`);
}

実際に存在しないid(100)でアクセスするとステータスコード404を持つResponseオブジェクトがthrowされカスタマイズした画面が表示されます。

CatchBoundaryで設定した画面の表示
CatchBoundaryで設定した画面の表示

lthrowするstatusの値を404から401に変更した場合にはCatchBoundaryの中で設定した条件に一致しないためカスタマイズした画面が表示されることはなく最初に確認したApplication Errorが表示されます。400 Not Foundや401のUnauthorizedなど事前にエラーの内容がわかっている場合はCatchBoundaryで条件を記述することそれぞれのエラーに応じたエラー画面を表示させることができます。CatchBoundaryの条件に一致しない、予期しないエラーが発生した場合にはCatchBoundaryでは対応することができません。その場合にはErrorBoundaryを利用することができます。

Error Boundaryの設定

CatchBoundaryコンポーネントと同様にErrorBoundaryコンポーネントも関数を設定して画面上に表示する内容を設定することができます。$postsId.jsxファイルにErrorBoundary関数を追加します。


export function ErrorBoundary({ error }) {
  return (
    <div>
      <h1>Error</h1>
      <p>{error.message}</p>
      <p>The stack trace is:</p>
      <pre>{error.stack}</pre>
    </div>
  );
}

$postId.jsxファイルの中でステータスコード401のエラーをthowします。CatchBoundarの設定で401がthrowされた場合の処理を行っていないためエラーはErrorBoundaryでcatchされ下記のようにErrorBoundaryで設定したエラーー画面が表示されます。

ErrorBoundaryの設定
ErrorBoundaryの設定

ErrorBoundaryの設定は$postId.jsxファイルで行ったためnested routeの親ページ(/routes/posts.jsx)以下のroutes/posts/index.jsxでErrorBoundaryによるエラーのcatchを行うことができません。posts.jsxでErrorBoundaryを設定することでindex.jsx, $postId.jsxで発生したエラーをcatchすることができるようになります。

$postId.jsxに記述したErrorBoundaryをposts.jsxに移動します。ErrorBoundaryの中身は変更していません。


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

export default function Post() {
  return (
    <>
      <h1>Post Page</h1>
      <Outlet />
    </>
  );
}

export function ErrorBoundary({ error }) {
  return (
    <div>
      <h1>Error</h1>
      <p>{error.message}</p>
      <p>The stack trace is:</p>
      <pre>{error.stack}</pre>
    </div>
  );
}

routes/posts/index.jsxファイルのloader関数で強制的にエラーを発生させると設定したErroBoundaryの画面が表示されます。


export async function loader() {
  throw new Error('エラー');
  const posts = await prisma.post.findMany();
  return json({ posts });
}
ErrorBoundaryによるエラー画面
ErrorBoundaryによるエラー画面

これでpost.jsx以下で発生したエラーはErrorBoundaryによってcatchできるようになりました。

そのほかにも機能はあるので今後本記事に追加を行うか別記事で公開する予定です。