🦁

RemixのJokes App TutorialをNext.jsでやった

2023/09/18に公開

はじめに

RemixのJokes App Tutorialをやりました。
https://remix.run/docs/en/main/tutorials/jokes

面白かったのでNext.jsでもやってみました。

https://github.com/Catminusminus/jokes-nextjs

Next.jsでのTutorialとの対応

Routes

Remixがapp/root.tsxapp/routes/jokes.tsxOutletコンポーネントを使っているのは、Next.jsではlayout.tsxchildrenを置く相当です。

Styling

今回大変だったところその1。Remixではlinks: LinksFunctionでCSSを指定しました。これは真似できないので、色々試した末excssを使うことにしました。
https://github.com/taishinaritomi/excss
これは元のCSSをほぼそのまま使えるので、今回の用途では神でした。これがなかったら記事は後1年は出ませんでした。
ちなみにexcssのcssと普通のclass名は空白を開けた文字列の結合でちゃんと結合されます。

        className={`${css`
            display: flex;
            flex-direction: column;
            justify-content: center;
            align-items: center;
            padding-top: 3rem;
            padding-bottom: 3rem;`} 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}`}>
            &quot;{randomJokeData.randomJoke.name}&quot; Permalink
          </Link>
        </>
      ) : (
        <>
          <p>{data.randomJoke.content}</p>
          <Link href={`jokes/${data.randomJoke.id}`}>
            &quot;{data.randomJoke.name}&quot; 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