LoginSignup
1
1

PHP/laravelのエンジニアがRemixのチュートリアルを途中までやってみた

Posted at

概要

Remix(TypeScript)のプロジェクトに参画させてもらえそうなのでまずはRemixのチュートリアルをやってみてざっくりどういうものか知ろうと思う。

すみません

「laravelのエンジニアが〜」というタイトルですが、別にRemixの開発方法をlaravelに置き換えて説明したりしてません。すみませんやろうと思いましたが、そんな器用なことはできなかった。

余談

「取っ掛かりは動画教材がいいな〜」っと思ってRemixの口座をUdemyで探したが海外講師さんの動画しかなく、公式チュートリアルをやってみることにした。(英語がぜんぜんできない自分にとってこの辺のハードルはできるだけ下げたかった。。。)

ちなみに自分のレベル感はフロントエンドはほぼやってなくて、laravelでAPIの開発とか、やったとしてもlaravelのMPAでbladeのテンプレートエンジンを使ってちょっと画面表示を修正したことがあるくらい。JavaScriptはProgateを一周したくらいでTypeScriptはほぼ知見はない。Reactもほぼ触ったことは無い。

やってみること

下記のチュートリアルを試してみる。(最近のフロントエンド由来のフレームワークは公式のチュートリアルがちゃんとしてて、すごくいいなあ。)

自分は「ちゃんと理解してから触り始める」ことができない人間なので、まずは真似しながらざっくりとどういうものか知りたい。あまりいいことではないがかなり惰性でこのチュートリアルに沿って体験してみようと思う。

おそらくDBやらデータ取得のサーバーやらはすでにようにされている模様なので比較的楽にチュートリアルを体験することができそう。

環境

今回は初めてなのでMacに直接環境を作ってチュートリアルを完走してみようと思う。(Dockerとかは使わない)
どうやらnpxコマンドが通る必要があるらしい、node.jsが入っていれば当該コマンドは使えるらしい。
自身のMacにはvoltaを使ってnode.jsが入ってる。

voltaのインストールの記事は自分で書いてなかったが、node.jsのバージョン切り替え記事はあったので記載しておく。

長々書いたが、npx -hのコマンドがMacのローカルでnot foundにならなければ多分大丈夫だろう。問題が出たらそのときに考えよう。

$ node -v
v18.18.2

$ npx -h
Run a command from a local or remote npm package

Usage:
npm exec -- <pkg>[@<version>] [args...]
npm exec --package=<pkg>[@<version>] -- <cmd> [args...]
npm exec -c '<cmd> [args...]'
npm exec --package=foo -c '<cmd> [args...]'

Options:
[--package <package-spec> [--package <package-spec> ...]] [-c|--call <call>]
[-w|--workspace <workspace-name> [-w|--workspace <workspace-name> ...]]
[-ws|--workspaces] [--include-workspace-root]

alias: x

Run "npm help exec" for more info

やってみる

テンプレートの作成

何はともあれ公式のチュートリアルに沿ってやってみよう。テンプレートなるものを用意するらしい。
どうやら下記のコマンドを実行するらしいので任意のディレクトリに移動してコピーして実行してみよう。

npx create-remix@latest --template remix-run/remix/templates/remix-tutorial

CleanShot 2024-05-05 at 18.16.00@2x.png

実行したら下記のようにインストール可否を問われるのでyを入力後Enterを押下した。(yを入力する必要無いかもしれない)

$ npx create-remix@latest --template remix-run/remix/templates/remix-tutorial
Need to install the following packages:
create-remix@2.9.1
Ok to proceed? (y)

下記のようにプロジェクト名を問われる。まあチュートリアルだし、サジェストされているデフォルトのままでEnterを押下するでいいだろう。

 remix   v2.9.1 💿 Let's build a better website...

   dir   Where should we create your new project?
         ./my-remix-app

下記のようにgitのイニシャライズをするか聞かれる。なんて親切なんだ、、、!Macのローカル直接だしYesでEnterを押下した。

   git   Initialize a new git repository? (recommended)
         ● Yes  ○ No

npmで依存関係のインストールするか聞かれるのでYESを選んだ。

  deps   Install dependencies with npm? (recommended)
         ● Yes  ○ No 

お、なんか終わったっぽいぞ。いやー親切、、!

  done   That's it!

         Enter your project directory using cd ./my-remix-app
         Check out README.md for development and deploy instructions.

         Join the community at https://rmx.as/discord

今作ってもらったプロジェクトのアプリケーションを起動してみようと思う。下記コマンドを実行してみよう。(このコマンドは公式ドキュメントのチュートリアルにも記載されている。)

cd ./my-remix-app
npm install
npm run dev

CleanShot 2024-05-05 at 18.24.23@2x.png

npm installでなんか脆弱性が指摘されているけど、まあエラーになったわけじゃないので続行する。

$ npm install

up to date, audited 796 packages in 3s

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

2 vulnerabilities (1 moderate, 1 high)

To address all issues (including breaking changes), run:
  npm audit fix --force

Run `npm audit` for details.

http://localhost:5173/ でどうやらアクセスできるらしい。アクセスしてみよう。

$ npm run dev

> dev
> remix vite:dev

  ➜  Local:   http://localhost:5173/
  ➜  Network: use --host to expose
  ➜  press h + enter to show help

おおお、チュートリアル通りの画面が開いた。安心する。ココまでで詰まらないようにできていることに感動する。

CleanShot 2024-05-05 at 18.30.21@2x.png

スタイルを当てる

先ほどまでの手順で表示された下記の画面だが、これはスタイルが当たっておらず表示内容が左によってしまっている。
自分専用のTODOリストならまだしもこの状態ではわかりにくい。そのためチュートリアルの内容に沿ってスタイルを当ててみる。

CleanShot 2024-05-05 at 18.30.21@2x.png

どうやら上記の画面表示を司っているファイルはmy-remix-app/app/root.tsxらしい。

チュートリアルに従って編集してスタイルを当ててみる。(紹介されているコードをまるっと、すでに記載されているimportの前に追記した。)

CleanShot 2024-05-05 at 18.40.27@2x.png

指定されたコードを雑に既存のimport文の前に突っ込んでみる。

CleanShot 2024-05-05 at 18.41.52@2x.png

スタイルが当たった。

CleanShot 2024-05-05 at 18.42.57@2x.png

一旦コミットして次に行ってみよう。

表示リンク先の画面を作ろう

今の状態はあくまでスタイルを当てただけだ。画面上のリンクをクリックしても404の画面が表示されるだけである。このリンク先の画面をこれから作っていく。

どうやら/contacts/1にアクセスしたときに開く画面を作るらしい。コマンドが明記されているので順に実行しよう。

mkdir app/routes
touch app/routes/contacts.\$contactId.tsx

CleanShot 2024-05-05 at 18.51.35@2x.png

まだチュートリアルの先を読んでないんだけど、おそらくapp/routes/直下のディレクトリ構造がルーティングのパス表現担っているんだろうなと予想した。$contactIdの部分でパスパラメーターの値を取ってる感じかな??
というか個人開発の方でReactを使ってて、ディレクトリ構造がそのままルーティングのパスになっているってSadoさんが教えてくれた。

パスパラメーターの裏付けとして下記のように書かれてたので間違いないと思う。

CleanShot 2024-05-05 at 18.58.16@2x.png

my-remix-app/app/routes/contacts.$contactId.tsxのファイルにチュートリアルの画面からコピーした内容を貼り付けてみる。

CleanShot 2024-05-05 at 18.58.58@2x.png

これで、画面上の「Your Name」とかのリンクをクリックしてみようと思う

CleanShot 2024-05-05 at 18.42.57@2x.png

特段画面に変化は無いように思えるが、まず404が返らなくなったのと、URLのパス部分がちゃんと/contact/1になっており、先程まで404が出ていたページであることがわかる。

CleanShot 2024-05-05 at 22.04.56@2x.png

ネストされたルーティング (ネストされたルートとアウトレット)

コンポーネントの共有的なことがおそらくできるっぽい。「ネストされたルーティング」って言葉がなんか難しく感じるし、自分の認識が間違えている可能性もあるけどとりあえず真似してみよう。

my-remix-app/app/root.tsxを開き、impoortにOutlet,を追記する。その後、57行目付近に下記のコードを追記する。

<div id="detail">
  <Outlet />
</div>

すべての修正後は下記のようになる。

my-remix-app/app/root.tsx
import type { LinksFunction } from "@remix-run/node";
// existing imports

import appStylesHref from "./app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

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

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix Contacts</h1>
          <div>
            <Form id="search-form" role="search">
              <input
                id="q"
                aria-label="Search contacts"
                placeholder="Search"
                type="search"
                name="q"
              />
              <div id="search-spinner" aria-hidden hidden={true} />
            </Form>
            <Form method="post">
              <button type="submit">New</button>
            </Form>
          </div>
          <nav>
            <ul>
              <li>
                <a href={`/contacts/1`}>Your Name</a>
              </li>
              <li>
                <a href={`/contacts/2`}>Your Friend</a>
              </li>
            </ul>
          </nav>
        </div>
        <div id="detail">
          <Outlet />
        </div>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

コンポーネントを使いまわしたり、ベースとなるコンポーネントを定義しておいて共通化を図れるよねってことだと思う。

/contacts/1にアクセスすると下記のように表示されたのでチュートリアルと一致している。

CleanShot 2024-05-05 at 22.43.33@2x.png

しかし、、、/contacts/1の画面表示に変更が加わったのに、なぜroot.tsxにOutletを追加したんだろう?なぜmy-remix-app/app/routes/contacts.$contactId.tsxに編集を加えないんだろう? /の画面表示を変更したならまだしもなんでだ!?!?

部分的なローディング(クライアント側ルーティング)

今のコードの状態でサイドバーの「Your Name」などのリンクをクリックすると、都度サーバーにリクエストを送っている。それを証明するためにサイドバーのリンクをクリックすると、ブラウザの「再読み込みマーク」が一時的に「✕」になる。このことから都度都度リクエストを送っていることがわかる。これをチュートリアルでは「URL に対する完全なドキュメント リクエスト」と言っているようだ。

CleanShot 2024-05-05 at 23.06.23.gif

まあこれでも悪くないが、最近のリッチなフロントエンドはコンポーネントごとに情報を取りに行ったりして、部分的にページを更新することもある。これをチュートリアルでは「クライアント側のルーティング」と言っているっぽい。

それを実現するには現在<a>要素を使っているサイドバーのリンクを書き換えて上げる必要があるらしい。

my-remix-app/app/root.tsxを開き、impoortにLink,を追記する。その後、サイドバーの「Your Name」などのタグ部分を<a>からLinkを使ったものに書き換える。

すべての修正後は下記のようになる。

my-remix-app/app/root.tsx
import type { LinksFunction } from "@remix-run/node";
// existing imports

import appStylesHref from "./app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

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

export default function App() {
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix Contacts</h1>
          <div>
            <Form id="search-form" role="search">
              <input
                id="q"
                aria-label="Search contacts"
                placeholder="Search"
                type="search"
                name="q"
              />
              <div id="search-spinner" aria-hidden hidden={true} />
            </Form>
            <Form method="post">
              <button type="submit">New</button>
            </Form>
          </div>
          <nav>
            <ul>
              <li>
                <Link to={`/contacts/1`}>Your Name</Link>
              </li>
              <li>
                <Link to={`/contacts/2`}>Your Friend</Link>
              </li>
            </ul>
          </nav>
        </div>
        <div id="detail">
          <Outlet />
        </div>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

この状態で/からサイドバー内部のリンクをクリックして/contacts/1などに遷移しても画面の読み込みが行われない。「クライアント側のルーティング」が行われていることがわかる。

CleanShot 2024-05-06 at 10.34.17.gif

コンポーネント単位でデータを取得(データのロード)

コンポーネント単位(今回はサイドバー)でサーバーからデータを取得して表示する方法を体験する。
今までサイドバーは「Your Name」などのリンクが存在していた。
しかしこれは、いわばハードコーディングされたリンクが設置されているだけだった。
この部分をサーバーから取得した文字列でリンクを作りたい場合、サイドバーのコンポーネント単位でデータを取得、表示するようにしたい。
その方法が下記にかかれている。追加するコードが比較的多く、ちょっと不安だが実際はサイドバーのコンポーネント内でサーバーからデータを取得して、取得したデータをサイドバーに表示しているだけなのでシンプルだったりする。

指示通りにmy-remix-app/app/root.tsxを修正してみた↓(修正範囲が大きいので個々の内容には触れない)

my-remix-app/app/root.tsx
import type { LinksFunction } from "@remix-run/node";
// existing imports

import appStylesHref from "./app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

import { getContacts } from "./data";

export const loader = async () => {
  const contacts = await getContacts();
  return json({ contacts });
};

export default function App() {
  const { contacts } = useLoaderData();
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix Contacts</h1>
          <div>
            <Form id="search-form" role="search">
              <input
                id="q"
                aria-label="Search contacts"
                placeholder="Search"
                type="search"
                name="q"
              />
              <div id="search-spinner" aria-hidden hidden={true} />
            </Form>
            <Form method="post">
              <button type="submit">New</button>
            </Form>
          </div>
          <nav>
            {contacts.length ? (
              <ul>
                {contacts.map((contact) => (
                  <li key={contact.id}>
                    <Link to={`contacts/${contact.id}`}>
                      {contact.first || contact.last ? (
                        <>
                          {contact.first} {contact.last}
                        </>
                      ) : (
                        <i>No Name</i>
                      )}{" "}
                      {contact.favorite ? (
                        <span></span>
                      ) : null}
                    </Link>
                  </li>
                ))}
              </ul>
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </nav>
        </div>
        <div id="detail">
          <Outlet />
        </div>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

この状態で/にアクセスするとサイドバーの内容が更新され、ハードコーディングしたものではなくサーバーから取得したデータが表示されていることがわかる。

CleanShot 2024-05-06 at 11.02.52@2x.png

ちなみに、上記のコードは30行目と60行目でTypeScriptの型のエラーが出るらしい。これは次で修正するから今は無視していいと書いてある。

型の指定(型推論)

TypeScriptは型に厳しい。簡単に言うと曖昧なデータ型は基本許されないと思っていいっぽい。
現在エディタ上で型エラーとなっている30行目と60行目を見てみよう。

まずは30行目、、、

useLoaderData()の戻り値の型が指定されていないっぽくて定数contactsにどのような型の値が入るかわかっていない状態っぽい。

CleanShot 2024-05-06 at 11.16.45@2x.png

更に60行明、、、

おそらく上記で触れた定数contatctsの内容を一個一個取り出してcontactに入れ込むなどしてサイドバーの表示をループ的に作っているところだろう。もともとの定数contatctsの型が指定されていないからcontactはany型になると言っている。any型って「何でも入ります」みたいなデータ型なはずで、かなり広義になってしまうので型に厳密なTypeScriptだと多様してほしくなくてエラーになるんじゃなかろうか。(TypeScriptのanyはPHPでいうところのmixedてきな感じ?)

CleanShot 2024-05-06 at 11.13.24@2x.png

本セクションでは、アノテーションを使って型をちゃんと指定してあげようぜ〜という内容っぽい。useLoaderData()の戻り値の型を明示的に指定してあげる感じ。(これはRemixの内容というよりはTypeScript側の話なのかな??)

30行目をconst { contacts } = useLoaderData();からconst { contacts } = useLoaderData<typeof loader>();のように書き換えてあげればおそらく型のエラーは消えるはず。

ココまでのコードを下記に記載する。

my-remix-app/app/root.tsx
import type { LinksFunction } from "@remix-run/node";
// existing imports

import appStylesHref from "./app.css?url";

export const links: LinksFunction = () => [
  { rel: "stylesheet", href: appStylesHref },
];

import { json } from "@remix-run/node";
import {
  Form,
  Link,
  Links,
  Meta,
  Outlet,
  Scripts,
  ScrollRestoration,
  useLoaderData,
} from "@remix-run/react";

import { getContacts } from "./data";

export const loader = async () => {
  const contacts = await getContacts();
  return json({ contacts });
};

export default function App() {
  const { contacts } = useLoaderData<typeof loader>();
  return (
    <html lang="en">
      <head>
        <meta charSet="utf-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1" />
        <Meta />
        <Links />
      </head>
      <body>
        <div id="sidebar">
          <h1>Remix Contacts</h1>
          <div>
            <Form id="search-form" role="search">
              <input
                id="q"
                aria-label="Search contacts"
                placeholder="Search"
                type="search"
                name="q"
              />
              <div id="search-spinner" aria-hidden hidden={true} />
            </Form>
            <Form method="post">
              <button type="submit">New</button>
            </Form>
          </div>
          <nav>
            {contacts.length ? (
              <ul>
                {contacts.map((contact) => (
                  <li key={contact.id}>
                    <Link to={`contacts/${contact.id}`}>
                      {contact.first || contact.last ? (
                        <>
                          {contact.first} {contact.last}
                        </>
                      ) : (
                        <i>No Name</i>
                      )}{" "}
                      {contact.favorite ? (
                        <span></span>
                      ) : null}
                    </Link>
                  </li>
                ))}
              </ul>
            ) : (
              <p>
                <i>No contacts</i>
              </p>
            )}
          </nav>
        </div>
        <div id="detail">
          <Outlet />
        </div>

        <ScrollRestoration />
        <Scripts />
      </body>
    </html>
  );
}

パスパラメーター(ローダーの URL パラメータ)

現在、サイドバーの任意の人名のリンクをクリックすると、すべて同一の静的なページが開かれる。
猫のアイコンが表示される静的なページのURLのパスを見ると、サイドバーでクリックした任意の人名のケバブケースがパスに入っている。(/contacts/christopher-chedeauなど)
このURLのパスのchristopher-chedeauの部分はパスパラメーターと言って可変の文字列を与え、文字列によって画面のふるまい分けに使われる事がある。

このセクションではおそらく、「パスパラメーターの値を受け取って、受け取った値を使ってサーバーにアクセスして、表示内容を出し分けよう」ということを体験させたいのだと思う。深く考えずとりあえず指示通りやってみよう。
コードを見る感じ、やはりパスパラメーターの値を使って情報を取得して、表示に使っていることがわかる。

今回編集を加えるのは今までいじってきたmy-remix-app/root.tsxではなくmy-remix-app/app/routes/contacts.$contactId.tsxになるので注意してほしい。お恥ずかしながら自分はめっちゃ詰まった。

ココまでのコードを下記に記載する。(ちなみにここまでの修正だけだと型のえラーがまた発生する。)

my-remix-app/app/routes/contacts.$contactId.tsx
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { getContact } from "../data";
import type { FunctionComponent } from "react";
import type { ContactRecord } from "../data";

export const loader = async ({ params }) => {
  const contact = await getContact(params.contactId);
  return json({ contact });
};

export default function Contact() {
  const { contact } = useLoaderData<typeof loader>();

  return (
    <div id="contact">
      <div>
        <img
          alt={`${contact.first} ${contact.last} avatar`}
          key={contact.avatar}
          src={contact.avatar}
        />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{" "}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter ? (
          <p>
            <a
              href={`https://twitter.com/${contact.twitter}`}
            >
              {contact.twitter}
            </a>
          </p>
        ) : null}

        {contact.notes ? <p>{contact.notes}</p> : null}

        <div>
          <Form action="edit">
            <button type="submit">Edit</button>
          </Form>

          <Form
            action="destroy"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );
              if (!response) {
                event.preventDefault();
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const favorite = contact.favorite;

  return (
    <Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </Form>
  );
};

再びの型の指定とパスパラメーターでデータが取れなかったときのふるまい(パラメータの検証と応答のスロー)

先にも記載したが型のエラーが発生している。本当は原理原則を理解して修正したほうがいいのかもしれないが一旦チュートリアルに沿って変更してみる。
まずは当該のファイルを下記のように修正をした。

my-remix-app/app/routes/contacts.$contactId.tsx
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { getContact } from "../data";
import type { FunctionComponent } from "react";
import type { ContactRecord } from "../data";
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  return json({ contact });
};

export default function Contact() {
  const { contact } = useLoaderData<typeof loader>();

  return (
    <div id="contact">
      <div>
        <img
          alt={`${contact.first} ${contact.last} avatar`}
          key={contact.avatar}
          src={contact.avatar}
        />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{" "}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter ? (
          <p>
            <a
              href={`https://twitter.com/${contact.twitter}`}
            >
              {contact.twitter}
            </a>
          </p>
        ) : null}

        {contact.notes ? <p>{contact.notes}</p> : null}

        <div>
          <Form action="edit">
            <button type="submit">Edit</button>
          </Form>

          <Form
            action="destroy"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );
              if (!response) {
                event.preventDefault();
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const favorite = contact.favorite;

  return (
    <Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </Form>
  );
};

まだ一部エラーは出るがかなり減ったように思える。

サイドバーの人名をクリックして正常に表示されることを確認しよう。
ここで一つ問題なのは、今はサイドバーの人名をクリックすると人名のケバブケースがパスパラメーターに含まれ、パスパラメーターの値を使ってサーバーに人のデータを取りに行っている。サイドバーからのクリックならもちろんサーバーに当該のデータが存在することがわかっているのでエラーにはならない。

例えば /contacts/hoge-fugaにブラウザのURL入力部分直接指定でアクセスを行った場合どうなるだろうか?(hoge-fugaという人物の情報はサーバーは持っていないこととする)

下記のようにデータにアクセスできない旨のエラーが画面に出てしまうのだ。

CleanShot 2024-05-06 at 13.23.35@2x.png

なので、チュートリアルに沿ってサーバーからnullが返った場合の対応を追加しよう。
下記のようにコードを修正した。

my-remix-app/app/routes/contacts.$contactId.tsx
import { json } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import { getContact } from "../data";
import type { FunctionComponent } from "react";
import type { ContactRecord } from "../data";
import type { LoaderFunctionArgs } from "@remix-run/node";
import invariant from "tiny-invariant";

export const loader = async ({
  params,
}: LoaderFunctionArgs) => {
  invariant(params.contactId, "Missing contactId param");
  const contact = await getContact(params.contactId);
  if (!contact) {
    throw new Response("Not Found", { status: 404 });
  }
  return json({ contact });
};

export default function Contact() {
  const { contact } = useLoaderData<typeof loader>();

  return (
    <div id="contact">
      <div>
        <img
          alt={`${contact.first} ${contact.last} avatar`}
          key={contact.avatar}
          src={contact.avatar}
        />
      </div>

      <div>
        <h1>
          {contact.first || contact.last ? (
            <>
              {contact.first} {contact.last}
            </>
          ) : (
            <i>No Name</i>
          )}{" "}
          <Favorite contact={contact} />
        </h1>

        {contact.twitter ? (
          <p>
            <a
              href={`https://twitter.com/${contact.twitter}`}
            >
              {contact.twitter}
            </a>
          </p>
        ) : null}

        {contact.notes ? <p>{contact.notes}</p> : null}

        <div>
          <Form action="edit">
            <button type="submit">Edit</button>
          </Form>

          <Form
            action="destroy"
            method="post"
            onSubmit={(event) => {
              const response = confirm(
                "Please confirm you want to delete this record."
              );
              if (!response) {
                event.preventDefault();
              }
            }}
          >
            <button type="submit">Delete</button>
          </Form>
        </div>
      </div>
    </div>
  );
}

const Favorite: FunctionComponent<{
  contact: Pick<ContactRecord, "favorite">;
}> = ({ contact }) => {
  const favorite = contact.favorite;

  return (
    <Form method="post">
      <button
        aria-label={
          favorite
            ? "Remove from favorites"
            : "Add to favorites"
        }
        name="favorite"
        value={favorite ? "false" : "true"}
      >
        {favorite ? "" : ""}
      </button>
    </Form>
  );
};

この状態で/contacts/hoge-fugaにアクセスすると404が返るようになって先程のようなエラーは出なくなる。

CleanShot 2024-05-06 at 13.27.02@2x.png

一旦ここまで!!続きは別の記事でまとめます。尻切れトンボですみません。

1
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
1
1