💿

Remixのコンセプトの一部をNext.jsに導入する(next-runtime)

2022/01/19に公開

Remixは何が優れているのか?

昨年11月下旬にRemixがv1のリリースを迎え、そのタイミングでパブリックなOSSとなりました。
https://remix.run/blog/remix-v1

それ以来私は、RemixとNext.jsの両方を業務とプライベートの中で行き来しなら開発しており、徐々にRemixの真髄的なものが見えてきました。
この記事では、私の感じたRemixの「ここが優れている」という点と、それらをNext.jsに取り入れる方法を紹介します。

Remixについては局所的なことしか述べませんので、包括的に詳しく知りたい方は、公式のドキュメントや、他の方々のわかりやすい記事を参考にしてください。

https://remix.run/docs/en/v1

https://zenn.dev/kaa_a_zu/articles/fbd06ca2cc3b86

https://zenn.dev/steelydylan/articles/remix-nextjs-comparison

なおこの記事ではNext.jsに関してはSSRのみを取り扱います。

ミューテーションのエンドポイント

Next.jsはリクエストレシーバがエンドポイントに対してgetServerSidePropsの一つです。一方、RemixはGETを受けるloaderと、GET以外のPOSTやDELETEなどを受けるactionの2つに別れており、役割が異なります。actionでは次のサンプルように簡潔にFormDataを参照することが可能です。

Next.jsのgetServerSidePropsでPOSTリクエストを受ける場合は、Bodyのパース処理を自身で書く必要があります。そのためAPI Routes を利用し、ミューテーション用のエンドポイントを増やすことが一般的です。

Remixのドキュメントより引用
import { redirect, Form } from "remix";
import { fakeGetTodos, fakeCreateTodo } from "~/utils/db";

export async function loader() {
  return fakeGetTodos();
}

export async function action({ request }) {
  const body = await request.formData();
  const todo = await fakeCreateTodo({
    title: body.get("title")
  });
  return redirect(`/todos/${todo.id}`);
}

export default function Todos() {
  const data = useLoaderData();
  return ...
}

https://remix.run/docs/en/v1/api/conventions#action

Remixは同一のエンドポイントでコンテントリクエストとミューテーションを行うことが可能で、これはRails等の従来WebフレームワークのようなRESTfulなエンドポイント構成と同じです。これによりコントローラロジックの凝縮が容易になります。
Next.jsでも同じように構成可能ですが、前述の通り一定の拡張が必要になりますので、ロジックとエンドポイントを分散させざるを得ません。

Cookieのハンドリングしやすさ

Next.jsでCookie取り扱う場合、外部ライブラリを利用するのが一般的です。利用せずともCookieをハンドリングすることはできますが、parseとsetに一手間も二手間も必要で、フレームワークとしてCookieをゴリゴリに活用する思想ではないことが伺えます。

一方でRemixはCookieのヘルパーがフレームワーク側に用意されています。
signed cookieにも対応しているため改ざんに対しての耐性もあります。

app/sessions.js (ドキュメントより引用)
import { createCookieSessionStorage } from "remix";

const { getSession, commitSession, destroySession } =
  createCookieSessionStorage({
    cookie: {
      name: "__session",
      domain: "remix.run",
      expires: new Date(Date.now() + 60),
      httpOnly: true,
      maxAge: 60,
      path: "/",
      sameSite: "lax",
      secrets: ["s3cret1"],
      secure: true
    }
  });

export { getSession, commitSession, destroySession };
app/routes/login.js (ドキュメントより引用)
import { json, redirect } from "remix";
import { getSession, commitSession } from "../sessions";

export async function loader({ request }) {
  const session = await getSession(
    request.headers.get("Cookie")
  );

  if (session.has("userId")) {
    return redirect("/");
  }

  const data = { error: session.get("error") };

  return json(data, {
    headers: {
      "Set-Cookie": await commitSession(session)
    }
  });
}

export async function action({ request }) {
  const session = await getSession(
    request.headers.get("Cookie")
  );
  const form = await request.formData();
  const username = form.get("username");
  const password = form.get("password");

  const userId = await validateCredentials(
    username,
    password
  );

  if (userId == null) {
    session.flash("error", "Invalid username/password");

    return redirect("/login", {
      headers: {
        "Set-Cookie": await commitSession(session)
      }
    });
  }

  session.set("userId", userId);
  return redirect("/", {
    headers: {
      "Set-Cookie": await commitSession(session)
    }
  });
}

https://remix.run/docs/en/v1/api/remix#sessions

単純なヘルパーが用意されているだけであれば、大した違いはありません。
しかしRemixはクライアントから可能な限りJavascriptを排除することをコンセプトにしており、状態管理をもフロントからサーバサイドに移譲するためは、Cookieの取り扱いの敷居を下げることが不可欠です
Remixはエッジファンクション上での動作をサポートしているので、ステートの返却が高速であるともこのコンセプトを後押ししています。

楽観的UI

先に述べた、Cookieを利用した状態管理のサーバサイド移譲に関して、エッジロケーションで動かすため高速で応答が得られるとはいえ、ローカルで実行結果を得るのとではレイテンシが全く違います。
その解決策として、Reimixは楽観的IU(レンダリング)による応答補完を行う仕組みをサポートしています。

app/routes/projects/new.js (ドキュメントより引用)
import { Form, redirect, useTransition } from "remix";
import { createProject } from "~/utils";
import { ProjectView } from "~/components/project";

export const action = async ({
  request
}) => {
  const body = await request.formData();
  const newProject = Object.fromEntries(body);
  const project = await createProject(newProject);
  return redirect(`/projects/${project.id}`);
};

export default function NewProject() {
  const transition = useTransition();
  return transition.submission ? ( // サブミット中にステータスやサブミット内容を観測できる
    <ProjectView
      project={Object.fromEntries(
        transition.submission.formData
      )}
    />
  ) : (
    <>
      <h2>New Project</h2>
      <Form method="post">
        <label>
          Title: <input type="text" name="title" />
        </label>
        <label htmlFor="description">Description:</label>
        <textarea name="description" id="description" />
        <button type="submit">Create Project</button>
      </Form>
    </>
  );
}

https://remix.run/docs/en/v1/guides/optimistic-ui

エンドポイントとの通信の状況をハンドリングしサブミット中のデータからレスポンスを予測(期待)して、先にUI側に反映させることで、ローディングスピナ等の結果を待つための挙動を排除します。

楽観的UIをご存じない方は下記記事がわかりやすいのでどうぞ。
https://kaminashi-developer.hatenablog.jp/entry/optimistic-update-in-spa


以上が私が思うRemixの優れている機能と思想です。

サイドコラム 「Nested Routes や Error Boundary は?」

Remixを説明する際に Nested Routes が必ず登場します。が、あえて今回の記事では取り扱っていません。
というのも、私自身この機能に関しては使い所を見つけられないというのが正直なところです。

もちろん、共通レイアウトであったり、/admin のようなアクセスコントロールが必要なルートにおいては使用していますし、Nested Routesのおかげでそういった処理はNext.jsよりも簡潔に書けます。
ただ、公式サイトにあるような、3階層・4階層に渡るネストコンテンツを管理することがなかなかありません。コンポネント的には階層構造にできても、Nested RoutesはURLの構造に大きく依存しますので、フル活用するにはURLの構造から見直さなければなりません。
また、単一にネストすることは可能ですが、リスト構造的なネストはサポートしていません。

ということで、私的に、Nested Routesは2階層程度であれば非常に便利ですが、それ以上の階層になってくると「まだ人類に早すぎた」感が否めないと思っています。
(もちろんフル活用できれば、先程あげた状態管理をサーバサイドへ移譲するという動きが、もっと加速することは理解しています。)

ちなみに、先日Remixの公式ブログで公開されたRemix vs Next.jsの中で、同じECサイトをRemixとNext.jsとで構築してパフォーマンス比較する下りがあるのですが、

Note that this app doesn't get to exercise everything we think is cool about Remix (like nested routes!).
このアプリは(nested routesのような)Remixのクールなところを、すべて発揮できるわけではないことに注意してください。

という注意書きがあります。
これが比較条件を揃えるためにあえてNested Routesを使っていないのか、Remixの開発チーム自身がそれを実際のサービスに適応するのを倦ねているのか、解釈はおまかせします。

Next.jsと比較したときにすべてが優れているかというと、もちろんそれは違います。
Next.jsはSSRとSSG(ISR)の両方に対応していますし、GoogleのAuroraチームと連携しているため、SEOやUXのベストプラクティスを取り入れやすいです。
Vercelの地の利を得れば、実験的な機能を含めて、さまざまなエコシステムに乗っかることができます。

私は両方のフレームワークを開発に取り入れていますが、大部分はそれぞれのレールに乗りつつ、優れたものであれば、部分的に思想を交換するということが重要だと思います。

ということで、上にあげたRemixの優れているところを、Next.jsに取り入るための方法について述べます。

  • 同一のエンドポイントでリクエストとミューテーションを行う
  • Cookieのハンドリング
  • 楽観的UI

Next.jsでRemixぽく書く

実は自力で実装するものは何もありません。next-runtimeというライブラリを一つ利用するだけです。
https://github.com/smeijer/next-runtime

Remixの思想をかなり強く受けており、ライブラリの目的自体が「RemixのようなAPIと哲学をNextに持たせること」だとドキュメントでも言っています。
https://next-runtime.meijer.ws/getting-started/1-introduction#credits

単一のエンドポイントでリクエストメソッドごとにレシーバを構える

next-runtimeの特筆すべき機能は下記のとおりです。

  • getServerSidePropsを拡張し、GETとPOSTとでロジックを分離させる
    • DELETE, PATCH, PUTにも対応している
    • ミューテーションのレシーバをAPI Routeを利用せずに書くことができる
    • 単一のエンドポイントでコンテントリクエストとミューテーションリクエストの両方を受けることができる
  • リクエスト時のaccept Headercontent-type Headerに応じて、レスポンスのコンテントタイプを自動的に切り替えてくれる
    • ページのエンドポイントに対してJSONを要求すれば、HTMLではなくpage propsのJSONが返却される
  • Bodyの自動パース
    • 処理本体となるコールバック記述時点で、パース済みのオブジェクトを得ることが可能

下記が公式ドキュメントにある、サンプルを少し修正したものです。
ご覧の通り、getServerSidePropsのロジックが、リクエスト(get)とミューテーション(post)に分割されていますし、post時のbodyも自動的にパースされていることがわかります。
また、フォームにactionアトリビュートを与えていないので、このフォームのデータは自身のURLにPOSTされることになります。
まさに、Remixのloaderactionを再現しています。

ドキュメントのサンプルを引用
import { handle, json } from 'next-runtime';

export const getServerSideProps = handle({
  async get({ params, query }) {
    return json({ name: 'Anonymous' });
  },

  async post({ req: { body } }) {
    return json({ message: `Thanks for your submission! Your name is ${body.name}` });
  },
  // 他にも patch, put, delete, upload 等がある
});

export default function Home({ name, message }) {
  if (message) {
    return <p>{message}</p>;
  }

  return (
    <form method="post">
      <input name="name" defaultValue={name} />
      <button type="submit">submit</button>
    </form>
  );
}

next-runtimeはCookieのハンドラも内包しています。Cookieの書き込みに関して言えば、remixよりも容易に記述できます。

https://next-runtime.meijer.ws/api/cookies

req.getCookieres.setCookieだけでなく、コンテキストから直接cookiesオブジェクトを取得可能です。
どちらを利用しても同じ結果を得られます。

ドキュメントより引用
export const getServerSideProps = handle({
  async get({ cookies }) {
    const session = cookies.get('session');
    cookies.set('session', Date.now(), { expires: 300 });
  },
});

export const getServerSideProps = handle({
  async get({ req }) {
    const session = req.getCookie('session');
  },
});

export const getServerSideProps = handle({
  async get({ res }) {
    res.setCookie('session', Date.now(), { expires: 300 });
  },
});

楽観的UIの対応

next-runtimeはFormコンポネントと、useFormSubmitというフォームのサブミット状態を監視するhooksを内包しています。
useFormSubmitから返却されるオブジェクトのステートを使用して楽観的UIを実装できます。

https://next-runtime.meijer.ws/api/use-form-submit

Formコンポネントは純粋なform elementに、サブミット用のロジックを載せているだけなので、react-hook-formなどのフォームライブラリとも併用可能です。

ここでサーバサイドでバリデーションを行うケースを考えてみましょう。

  1. 条件を満たさないケースでは、エラーメッセージを返却し、現在のページにとどまらせる
  2. 条件を満たせばデータを保存して完了ページへナビゲート(リダイレクト)する

Formコンポネントを利用しなくとも、ピュアなformタグとブラウザネイティブなサブミット処理でのリクエストで実装できます。
しかし、その場合エラーメッセージの返却からレンダリングまで、フルページリロードを挟まずに行うことはできません。

Formコンポネントはevent.preventDefaultでデフォルトの挙動を停止し、内部でfetch関数による通信に書き換えてハンドリングすることで、最終的にナビゲートすべきか、とどまらせて再レンダリングすべきかをコントロールしています。

import { Form } from 'next-runtime/form';
import { handle, json, redirect } from 'next-runtime';

export const getServerSideProps = handle({
  get: async () => {
    return json({})
  },
  post: async ({ req: { body } }) => {
    // 次のページに自動ナビゲートされる
    if (body.name.length > 10) return redirect('/next-page')

    // 現在のページにとどまり、コンポネントのみが再レンダリングされる
    return json({ error: 'nameは10文字上で入力してください' })
  }
})

const MyPage = ({ error }) => {
  const form = useFormSubmit();

  if (form.isLoading) {
    // 楽観的UI
    return <p>{form.values.name}</p>;
  }
  
  return (
    <Form method="post">
      <p>{error}</p>
      <input name="name" />
      <button type="submit">submit</button>
    </Form>
  );
}

[応用] next-runtimeとMVCっぽく書くことも一応可能

データローダはPrisma(M)
pagesはコンポネントだけ記述(V)
getServerSidePropsはnext-runtimeで、pageから分離させて別ファイルに凝縮(C)
とすると、ファイルの構成的にRailsっぽいMVCな感じでプロジェクトを構築できます。

controllers/user.form.controller.js
import { handle, json, redirect } from 'next-runtime'
import { getUser } form '@/models/user.model.ts'

export const form = handle({
  async get(ctx) {
    try {
      const user = await getUser(ctx)
      const store = await user.getData()
      return json(store.userForm)
    } catch (e) {
      return redirect('/')
    }
  },
  
  async post(ctx) {
    const { req: { body } } = ctx
    try {
      const user = await getUser(ctx)
      await user.postData(body)
      return redirect('/user/confirm')
    } catch (e) {
      return json({ error: e.message })
    }
  }
})

export const confirm = handle({
  ...
})
pages/user/form.tsx
export { form as getServerSideProp } from '@/controllers/user.form.controller'

const Page = (props) => {
  return ...
}

export default Page

まとめ

Remixの紹介なのか、Next.jsとnext-runtimeの紹介よくわからなくりましたが、RemixもNext.jsも非常に優秀なフレームワークだと私は思います。
適材適所で使い分けたり、いいとこ取りしたりということが大切ですが、そのためにもそれぞれのコンセプトを理解して使用する必要がありますね。
今後のアップデートで、いろいろなトレンドをや思想を反映して成長していくはずなので、ウォッチし続けたいと思います。

GitHubで編集を提案

Discussion