RemixのJokes App TutorialをNext.jsでやった
はじめに
RemixのJokes App Tutorialをやりました。
面白かったのでNext.jsでもやってみました。
Next.jsでのTutorialとの対応
Routes
Remixがapp/root.tsx
やapp/routes/jokes.tsx
でOutlet
コンポーネントを使っているのは、Next.jsではlayout.tsx
でchildren
を置く相当です。
Styling
今回大変だったところその1。Remixではlinks: LinksFunction
でCSSを指定しました。これは真似できないので、色々試した末excssを使うことにしました。
これは元のCSSをほぼそのまま使えるので、今回の用途では神でした。これがなかったら記事は後1年は出ませんでした。
ちなみにexcssのcss
と普通のclass名は空白を開けた文字列の結合でちゃんと結合されます。
className={`${css``} content`}
// これでexcssによって生成されたclass名とcontentが空白を開けて結合される
Read from the database in a Remix loader~Mutations
今回大変だったところその2。今回はAPI Routesを(RSS以外)使わず、Server Actionsを使いました。また、Server Componentもかなり使いました。
結果、random jokeを取得する部分がかなり大変でした。
まず、普通に(?)Server Componentで<Link href="/jokes">
とやってクリックしても、別にjokeは更新されません。
そこで、onClickで新たにrandom jokeをとってくることにしました。その結果、この部分をClient Componentにする必要が出てきました。
しかも、random jokeをとってくる部分(A)とそれを表示する部分(B)は少し遠いです。
しかしAからBの全部をClient Componentにはしたくなく・・・
なので、ここはコンポーネントを分けRecoilを使ってデータを共有することにしました。するとRecoilRoot
で囲う必要があります。でもAとBの間にはServer Componentもあります。
と言うわけで以下のようになりました。まず、app/jokes/layout.tsx
はServer Componentです。
export default async function JokesRoute({
children,
}: { children: React.ReactNode }) {
const data = await loader();
return (
<div className="jokes-layout">
<header className="jokes-header">
<div className="container">
<h1 className="home-link">
<Link href="/" title="Remix Jokes" aria-label="Remix Jokes">
<span className="logo">🤪</span>
<span className="logo-medium">J🤪KES</span>
</Link>
</h1>
{data.user ? (
<div className="user-info">
<span>{`Hi ${data.user.username}`}</span>
<form action={logout}>
<button type="submit" className="button">
Logout
</button>
</form>
</div>
そして、Recoilを使いたい部分がここです。
<main className="jokes-main">
<div className="container">
<ClientRoot data={data}>{children}</ClientRoot>
</div>
</main>
ここでClientRoot
はClient Componentです。
export function ClientRoot({
data,
children,
}: PropsWithChildren<{ data: Awaited<ReturnType<typeof loader>> }>) {
return (
<RecoilRoot>
<div className="jokes-list">
<RandomLink data={data} />
<Link href="/jokes/new" className="button">
Add your own
</Link>
</div>
<div className="jokes-outlet">{children}</div>
</RecoilRoot>
);
}
ここでRandomLinkは以下です。
export function RandomLink({
data,
}: { data: Awaited<ReturnType<typeof loader>> }) {
const setRandomJoke = useSetRandomJoke();
const [_, startTransaction] = useTransition();
const handleSubmit = () => {
startTransaction(async () => {
const res = await randomLoader();
setRandomJoke((t) => res);
});
};
return (
<>
<Link onClick={handleSubmit} href="/jokes">
Get a random joke
</Link>
<p>Here are a few more jokes to check out:</p>
<ul>
{data.jokeListItems.map(({ id, name }) => (
<li key={id}>
<Link href={`/jokes/${id}`}>{name}</Link>
</li>
))}
</ul>
</>
);
}
Linkをクリックすると、Server Actionをcustom invocationして手元のrandom jokeを更新します。
app/jokes/page.tsx
はこうです。
export default async function JokesIndexRoute() {
const data = await randomLoader();
return (
<div>
<Random data={data} />
</div>
);
}
これはServer Componentです。そしてRandom
はこうです。
export function Random({
data,
}: { data: Awaited<ReturnType<typeof randomLoader>> }) {
const randomJokeData = useRandomJoke();
return (
<>
{randomJokeData ? (
<>
<p>{randomJokeData.randomJoke.content}</p>
<Link href={`jokes/${randomJokeData.randomJoke.id}`}>
"{randomJokeData.randomJoke.name}" Permalink
</Link>
</>
) : (
<>
<p>{data.randomJoke.content}</p>
<Link href={`jokes/${data.randomJoke.id}`}>
"{data.randomJoke.name}" Permalink
</Link>
</>
)}
</>
);
}
つまり、random jokeの初期値はServer ComponentがServer Actionでとってきたやつで、Linkをクリックしたらcustom invocationしてRecoilで管理するわけです。
Authentication~User Registration
今回大変だったところその3。Password認証でcookieでセッション管理するわけですが、これはRemix内部の実装をそのまま持ってきました。特にRedirectしつつCookieを弄る方法がServer Actionで見つからなかったので、redirect
関数の実装を改変したredirectWithCookie
を実装して使いました。
皆さんはやめましょう。
Unexpected errors&Expected errors
ここはまだやってる最中です。
Remixがhooksでやるのに対し、error
はコンポーネントの引数に入ってきます。
export default function ErrorBoundary({ error }: { error: Error }) {
return (
<div className="error-container">
<h1>App Error</h1>
<pre>{error.message}</pre>
</div>
);
}
大きなところではNext.jsでは以下の2つが違います。
- error.tsxとglobal-error.tsxがある
- Remixのようにloaderが400を返して、みたいなことがServer Actionsだとできない
後者はどうしても400を返したいならRoute Handlersを使うことになると思います。エラー相当のデータを返したいだけならcustom invocationも使えます。
SEO with Meta tags
これはMetadata objectとgenerateMetadata
関数でやってます。
前者はこんな感じです。
export const metadata: Metadata = {
title: "Next.js: So great, it's funny!",
description,
twitter: {
description,
},
};
後者はこんな感じです。
export async function generateMetadata({
params,
}: { params: { jokeId: string } }) {
const data = await loader(params);
const { description, title } = data
? {
description: `Enjoy the "${data.joke.name}" joke and much more`,
title: `"${data.joke.name}" joke`,
}
: { description: "No joke found", title: "No joke" };
return {
title,
description,
twitter: {
description,
},
};
}
ここでloader
はServer Actionで、実は(Server)コンポーネントの中でも呼んでいて、二度手間なのが少し気がかりです。適当にキャッシュしろと言う話かもしれませんが・・・
Resource Routes
API Routesで返しています。
Forms
強いて言えばServer Actions周りが(項目としては)対応。
Prefetching
Defaultでtrueです。
Optimistic UI
Remixではconst navigation = useNavigation();
してnavigation.formData
を見て分岐しています。
Next.jsでReactのexperimental_useOptimistic
を用いました。
元々Jokeの追加はServer Actionをcustom invocationしているので、そこでServer Actionを呼び出す前にformdataをuseOptimistic
の返り値の2番目の関数に渡しています。
感想
結構大変でした。
Remixが(SSRの)Client ComponentとServer側のloader/actionでやるのに対して、Next.jsはServer側にServer ActionsとServer Componentがあって、より全体として細かく制御していく感覚です。結果、Remixの場合使わなかったClient側での状態管理も必要になりました。そして、そういったものやイベントハンドラが必要なものはClient Componentにして、それ以外はサーバーコンポーネントにして、、、
後はLoginのところでCookieつけてRedirectしてもLogin状態にならない(ブラウザでReloadしたらLogin状態になる)バグが発生し延々悩んでいたのですがNext.jsをアップデートしたら直りました。Metadataが遷移時アップデートされないのもアップデートで直りました。そうしたらまたLoginのところでCookieつけてRedirectしてもLogin状態になら(ry。GitHubにあるものはどちらも動くバージョンで固定しています
Next.jsとRemixで同じものを作ることで、Remixへの理解も深まった気がします。PhilosophyのServer/Client ModelとかWeb Standards, HTTP, HTMLとか正直何いってるのか最初分からなかったのですが、今ではちょっとわかる気がします。
Discussion