概要
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
実行したら下記のようにインストール可否を問われるので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
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
おおお、チュートリアル通りの画面が開いた。安心する。ココまでで詰まらないようにできていることに感動する。
スタイルを当てる
先ほどまでの手順で表示された下記の画面だが、これはスタイルが当たっておらず表示内容が左によってしまっている。
自分専用のTODOリストならまだしもこの状態ではわかりにくい。そのためチュートリアルの内容に沿ってスタイルを当ててみる。
どうやら上記の画面表示を司っているファイルはmy-remix-app/app/root.tsx
らしい。
チュートリアルに従って編集してスタイルを当ててみる。(紹介されているコードをまるっと、すでに記載されているimportの前に追記した。)
指定されたコードを雑に既存のimport文の前に突っ込んでみる。
スタイルが当たった。
一旦コミットして次に行ってみよう。
表示リンク先の画面を作ろう
今の状態はあくまでスタイルを当てただけだ。画面上のリンクをクリックしても404の画面が表示されるだけである。このリンク先の画面をこれから作っていく。
どうやら/contacts/1
にアクセスしたときに開く画面を作るらしい。コマンドが明記されているので順に実行しよう。
mkdir app/routes
touch app/routes/contacts.\$contactId.tsx
まだチュートリアルの先を読んでないんだけど、おそらくapp/routes/
直下のディレクトリ構造がルーティングのパス表現担っているんだろうなと予想した。$contactId
の部分でパスパラメーターの値を取ってる感じかな??
というか個人開発の方でReactを使ってて、ディレクトリ構造がそのままルーティングのパスになっているってSadoさんが教えてくれた。
パスパラメーターの裏付けとして下記のように書かれてたので間違いないと思う。
my-remix-app/app/routes/contacts.$contactId.tsx
のファイルにチュートリアルの画面からコピーした内容を貼り付けてみる。
これで、画面上の「Your Name」とかのリンクをクリックしてみようと思う
特段画面に変化は無いように思えるが、まず404が返らなくなったのと、URLのパス部分がちゃんと/contact/1
になっており、先程まで404が出ていたページであることがわかる。
ネストされたルーティング (ネストされたルートとアウトレット)
コンポーネントの共有的なことがおそらくできるっぽい。「ネストされたルーティング」って言葉がなんか難しく感じるし、自分の認識が間違えている可能性もあるけどとりあえず真似してみよう。
my-remix-app/app/root.tsx
を開き、impoortにOutlet,
を追記する。その後、57行目付近に下記のコードを追記する。
<div id="detail">
<Outlet />
</div>
すべての修正後は下記のようになる。
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
にアクセスすると下記のように表示されたのでチュートリアルと一致している。
しかし、、、/contacts/1
の画面表示に変更が加わったのに、なぜroot.tsx
にOutletを追加したんだろう?なぜmy-remix-app/app/routes/contacts.$contactId.tsx
に編集を加えないんだろう? /
の画面表示を変更したならまだしもなんでだ!?!?
部分的なローディング(クライアント側ルーティング)
今のコードの状態でサイドバーの「Your Name」などのリンクをクリックすると、都度サーバーにリクエストを送っている。それを証明するためにサイドバーのリンクをクリックすると、ブラウザの「再読み込みマーク」が一時的に「✕」になる。このことから都度都度リクエストを送っていることがわかる。これをチュートリアルでは「URL に対する完全なドキュメント リクエスト」と言っているようだ。
まあこれでも悪くないが、最近のリッチなフロントエンドはコンポーネントごとに情報を取りに行ったりして、部分的にページを更新することもある。これをチュートリアルでは「クライアント側のルーティング」と言っているっぽい。
それを実現するには現在<a>
要素を使っているサイドバーのリンクを書き換えて上げる必要があるらしい。
my-remix-app/app/root.tsx
を開き、impoortにLink,
を追記する。その後、サイドバーの「Your Name」などのタグ部分を<a>
からLink
を使ったものに書き換える。
すべての修正後は下記のようになる。
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
などに遷移しても画面の読み込みが行われない。「クライアント側のルーティング」が行われていることがわかる。
コンポーネント単位でデータを取得(データのロード)
コンポーネント単位(今回はサイドバー)でサーバーからデータを取得して表示する方法を体験する。
今までサイドバーは「Your Name」などのリンクが存在していた。
しかしこれは、いわばハードコーディングされたリンクが設置されているだけだった。
この部分をサーバーから取得した文字列でリンクを作りたい場合、サイドバーのコンポーネント単位でデータを取得、表示するようにしたい。
その方法が下記にかかれている。追加するコードが比較的多く、ちょっと不安だが実際はサイドバーのコンポーネント内でサーバーからデータを取得して、取得したデータをサイドバーに表示しているだけなのでシンプルだったりする。
指示通りに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>
);
}
この状態で/
にアクセスするとサイドバーの内容が更新され、ハードコーディングしたものではなくサーバーから取得したデータが表示されていることがわかる。
ちなみに、上記のコードは30行目と60行目でTypeScriptの型のエラーが出るらしい。これは次で修正するから今は無視していいと書いてある。
型の指定(型推論)
TypeScriptは型に厳しい。簡単に言うと曖昧なデータ型は基本許されないと思っていいっぽい。
現在エディタ上で型エラーとなっている30行目と60行目を見てみよう。
まずは30行目、、、
useLoaderData()
の戻り値の型が指定されていないっぽくて定数contacts
にどのような型の値が入るかわかっていない状態っぽい。
更に60行明、、、
おそらく上記で触れた定数contatcts
の内容を一個一個取り出してcontact
に入れ込むなどしてサイドバーの表示をループ的に作っているところだろう。もともとの定数contatcts
の型が指定されていないからcontact
はany型になると言っている。any型って「何でも入ります」みたいなデータ型なはずで、かなり広義になってしまうので型に厳密なTypeScriptだと多様してほしくなくてエラーになるんじゃなかろうか。(TypeScriptのanyはPHPでいうところのmixedてきな感じ?)
本セクションでは、アノテーションを使って型をちゃんと指定してあげようぜ〜という内容っぽい。useLoaderData()
の戻り値の型を明示的に指定してあげる感じ。(これはRemixの内容というよりはTypeScript側の話なのかな??)
30行目をconst { contacts } = useLoaderData();
からconst { contacts } = useLoaderData<typeof loader>();
のように書き換えてあげればおそらく型のエラーは消えるはず。
ココまでのコードを下記に記載する。
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になるので注意してほしい。お恥ずかしながら自分はめっちゃ詰まった。
ココまでのコードを下記に記載する。(ちなみにここまでの修正だけだと型のえラーがまた発生する。)
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>
);
};
再びの型の指定とパスパラメーターでデータが取れなかったときのふるまい(パラメータの検証と応答のスロー)
先にも記載したが型のエラーが発生している。本当は原理原則を理解して修正したほうがいいのかもしれないが一旦チュートリアルに沿って変更してみる。
まずは当該のファイルを下記のように修正をした。
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という人物の情報はサーバーは持っていないこととする)
下記のようにデータにアクセスできない旨のエラーが画面に出てしまうのだ。
なので、チュートリアルに沿ってサーバーからnullが返った場合の対応を追加しよう。
下記のようにコードを修正した。
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が返るようになって先程のようなエラーは出なくなる。
一旦ここまで!!続きは別の記事でまとめます。尻切れトンボですみません。