🦊

Next.js と Remix を比較してみた

2023/11/10に公開2

概要

今回は、最近フロントエンド界隈で少し話題になった Next.js と Remix を比較した記事を読み、実際に Remix がどんな感じなのか気になって自分でも触ってみたため、その内容を新たな記事としてまとめていこうと思います。

基本的には、Remix の公式のチュートリアルやドキュメントを参考にまとめたため、大きな解釈違いはないと思っているのですが、間違っている箇所などあれば気軽に指摘していただけると嬉しいです。

また、以下で紹介する Next.js と Remix の比較記事を元にした記事になっていますが、結論あまり記事は関係なく、Remix を触ってみたよという程度の記事になっている点にご了承ください。

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

記事の内容

まず最初に、私が読んだ Next.js と Remix を比較した記事の内容をざっとまとめていこうと思います。

  • Next.js は独自の技術が多すぎる
    Next.js 独自の技術が多すぎる一方で、Remix は標準の技術に焦点を当てているます。そのため、Remixに対しての技術向上は、Webアプリケーション開発の技術が向上するのと同じだと言えます。Next.jsから別のフレームワークに移行した時、Next.jsの上達に費やした時間が無駄になりますが、Remix だとWeb開発全般のスキルが向上するようになっています。

  • Vercel へのデプロイが前提にされている
    Next.js で開発したアプリケーションが、Vercel か Vercel 以外にデプロイするかで、ドキュメントに記載されているのと異なるフレームワークになってしまいます。Remix は、JavaScript が実行できる環境ならどこでも動くように作られています。

  • React に依存しすぎている
    React がオープンな財団によって開発されているなら良いですが、会社として Meta に不安を感じています。また、Vercel が React の開発メンバーを多く雇用し始めてから、React チームと Vercel の関係もあまりよくないため、悪い兆候が多くみられます。

  • ユーザーで色々実験している
    React チームがカナリアリリースとして出している機能を、Next.js が安定版としてリリースを行なっています。実験的な機能を安定したものとして宣伝する Next.js のチームには懸念しています。

  • 魔法が多すぎる
    Principle of least astonishmentの原則に反しています。標準の fetch 関数をオーバーライドして、自動キャッシュの機能の追加するのは大きな問題です。あとシンプルに複雑すぎます。

  • 安全性が乏しい
    高頻度のアップデートを行なっていますが、不安定で不満の声がたくさんあります。繰り返しになりますが、カナリアリリースされている機能をラップして安定しているというのは、全くもって納得できないです。

記事の項目に沿ってまとめたため、重複する箇所もいくつかあるのですが、まとめると 「複雑すぎる」「安全性が乏しい」 というところが大きく挙げられるのかなと思います。

https://www.epicweb.dev/why-i-wont-use-nextjs

実際に試してみる

次に、実際に Remix の公式チュートリアルを試し、Remix 独自の技術についてドキュメントから調べてみました。以下簡単に Remix のメイン機能(だと感じたところ)をまとめてみました。

Nested Routes

Nested Routes は URL をスラッシュ(/)区切りに分解し、その分解された要素を階層的に定義するデータ構造のことです。(以下の動画を見るとわかりやすいと思います)

nested routes

このように設計されていることで、別のページに遷移した時でも遷移先がネストされたページへの遷移の場合、ページ全体がレンダリングされるのではなく、特定の箇所のみのマウントで済むため、ページを高速に表示させることができます。また、前ページで取得を行ったサーバーから返されるデータに関しても、再取得を行う必要がなく、サーバーへのリクエストにおいても利点があります。

さらに、このネストされたルートによって、ページを並行で取得することも可能になっています。
例えば、/sales/invoicesのページへ遷移した際、Nested Routesでない実装だと、/sales/invoicesのページをリクエストし、ページを表示します。ですが、Nested Routesで実装を行っている場合、/sales/sales/invoicesの2回を並列でリクエストしてくれます。このように複数のリクエストを同時に行えることで、より応答性の高いアプリケーションを実現することができます。

少し余談ですが、この技術的なアプローチは、モジュール性と関心の分離の原則に則って設計されており、各ルート(パス)がそのパスの中心的な内容の実装に集中できるようにすると言う背景があったりするそうです。

https://remix.run/docs/en/main/discussion/routes

loader/action

loader/action

loader と action はサーバーの処理を行うための関数になります。わかりやすくざっくり言うと、GETの処理を行うときには loader、その他POST PUT DELETEの処理を行うときには action に処理を記載することになります。loader で取得したデータをページコンポーネントに伝える方法も Remix のカスタムフックとして提供されており、action でデータの更新を行う方法も関数を発火させる Form のコンポーネントとして Remix が提供しており、基本的にはその形に則ることでサーバーとのやり取りを完結させることができます。

export async function loader() {
  // provides data to the component
}

export default function Component() {
  // renders the UI
}

export async function action() {
  // updates persistent data
}

また、action 関数が発火し、サーバーのデータが更新されると、それに伴って loader も再度発火し、データの再検証を自動で行ってくれます。それによって、UI とサーバーの状態を常に同期させることが可能になっています。

https://remix.run/docs/en/main/discussion/data-flow

Next.js 比較

では、実際に Next.js との比較を行ってみたいと思います。
ですが、「Next.js の方がこうで、Remix の方がこう」みたいな比較だと主観が強くなりそうだなぁと思ったので、同じような実装を Next.js と Remix で行ったときにどこが異なるのか判別する方法で見ていきたいと思います。

実装した内容は、以下のようになります。

  1. サーバーからデータの一覧取得を行う
  2. 一覧ページから新たにデータを作成するページに遷移することができる
  3. 作成した後、一覧ページに再度遷移する

※ ポケモンのAPIを叩いて実際に動かしてみよう!と思ったのですが、POSTとかPUTの処理が面倒だったので、それっぽく書いています。もしかしたら、所々実装間違っている箇所があるかもしれないので、ご了承ください🙇

// Next.js

// /pokemon
import Image from 'next/image';
import { useRouter } from 'next/router';
import { useCallback } from 'react';

import type { GetServerSideProps } from 'next';

type Response = {
  id: number;
  name: string;
  sprites: { front_default: string; }
}

export const getServerSideProps: GetServerSideProps = async () => {
  const poke = await fetch('https://pokeapi.co/api/v2/pokemon/ditto').then((res) => res.json()) as Response;
  return {
    props: { poke }
  }
};

const PokemonPage = (props: { poke: Response }) => {
  const { poke } = props;

  const router = useRouter();

  const handlePokemonCreate = useCallback(async () => {
    const createPokemon = await createEmptyPokemon();
    router.push(`/pokemon/${ createPokemon.id }/create`);
  }, [router]);

  return (
    <div>
      <h1>pokemon</h1>
      <p>name: { poke.name }</p>
      <Image
        src={ poke.sprites.front_default }
        width={ 100 }
        height={ 100 }
        alt=''
      />
      <button onClick={ handlePokemonCreate }>Pokemon Create</button>
    </div>
  );
}

export default PokemonPage;

// /pokemon/[pokemonId]/create
import { useRouter } from 'next/router';
import { ChangeEvent, useCallback, useState } from 'react';

import type { GetServerSideProps } from 'next';

type Response = {
  id: number;
  name: string;
  sprites: { front_default: string; }
}

export const getServerSideProps: GetServerSideProps = async ({ params }) => {
  const pokemonId = params?.pokemonId as string;
  const poke = await getPokemon(pokemonId);
  if (!poke) {
    return { notFound: true }
  }
  return {
    props: { poke }
  };
};

const PokemonCreatePage = (props: { poke: Response }) => {
  const { poke } = props;

  const router = useRouter();
  const pokemonId = router.query.pokemonId as string;

  const [name, setName] = useState(poke.name);

  const handleNameChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
    setName(e.target.value);
  }, []);

  const handlePokemoEdit = useCallback(async () => {
    await updatePokemon(pokemonId, name);
    router.push(`/pokemon`);
  }, [name, pokemonId, router]);

  return (
    <form onSubmit={ handlePokemoEdit }>
      <p>
        <span>Name</span>
        <input
          onChange={ handleNameChange }
          defaultValue={ name }
          aria-label="name"
          name="name"
          type="text"
          placeholder="Name"
        />
      </p>
      <button type="submit">Save</button>
    </form>
  );
}

export default PokemonCreatePage;
// Remix

// /pokemon
import { json, redirect } from "@remix-run/node";

import {
  Form,
  useLoaderData,
} from "@remix-run/react";

type Response = {
  id: number;
  name: string;
  sprites: { front_default: string; }
}

export const loader = async () => {
  const poke = await fetch('https://pokeapi.co/api/v2/pokemon/ditto').then((res) => res.json()) as Response;
  return json({ poke });
};

export const action = async () => {
  const createPokemon = await createEmptyPokemon();
  return redirect(`/pokemon/${createPokemon.id}/create`);
};

export default function Pokemon() {
  const { poke } = useLoaderData<typeof loader>();

  return (
    <div>
      <h1>pokemon</h1>
      <p>name: {poke.name}</p>
      <img src={poke.sprites.front_default} alt="" />
      <Form method="post">
        <button type="submit">Pokemon Create</button>
      </Form>
    </div>
  )
}

// /pokemon/$pokemonId/create
import type { ActionFunctionArgs, LoaderFunctionArgs } from "@remix-run/node";
import { json, redirect } from "@remix-run/node";
import { Form, useLoaderData } from "@remix-run/react";
import invariant from "tiny-invariant";

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

export const action = async ({
  params,
  request,
}: ActionFunctionArgs) => {
  invariant(params.pokemonId, "Missing contactId param");
  const formData = await request.formData();
  const updates = Object.fromEntries(formData);
  await updatePokemon(params.pokemonId, updates);
  return redirect(`/pokemon`);
};

export default function CreatePokemon() {
  const { poke } = useLoaderData<typeof loader>();

  return (
    <Form id="pokemon-form" method="put">
      <p>
        <span>Name</span>
        <input
          defaultValue={poke.first}
          aria-label="name"
          name="name"
          type="text"
          placeholder="Name"
        />
      </p>
      <button type="submit">Save</button>
    </Form>
  )
}

同じ実装をそれぞれ比較したところ、個人的には Remix の方がシンプルに書けるなぁという印象がありました。
「loader/action がサーバーとの処理を行ってくれる」というところの理解をした上でコードを見てみると、UI のためのロジックと、サーバー関連のロジックで関心の分離がされているため、パッと見ただけでどこで何をしているのかが判断できるのが良いなと感じました。

また、このコードの比較には使用する機会があまりありませんでしたが、Remix が提供しているカスタムフックやコンポーネントも有用なものがたくさんあり、Remix のチュートリアルや Next.js との比較コードを書いている中で「使いやすい」というのが印象として強かったです。

結論とまとめ

ここまで書いた上で、最初に挙げた記事にあった Next.js は Remix と比べて「複雑すぎる」「安全性が低い」というところが実際どうだったかというと、「チュートリアル + ちょっと自分で触ってみた程度だとわからない」という感じでした。

Next.js との比較のところで使いやすいと記載しましたが、記事に挙がっている複雑というところはコードの書き方や可読性などの話ではなく、内部実装などの裏側の処理に対しての意味が多く含まれていそうでした。そのため、Next.js は複雑で Remix はシンプルで使いやすいという評価はできないと感じました。

また、安全性のところに関しても、Next.js が安全性が乏しいというのは記事の内容で納得できるところが多かったですが、一方で Remix が安全だよねというのも、もう少し Remix のアップデートやディスカッションなどを追っていかないと判別できそうにないところでした。

結論、最初に挙げた記事が正当な主張かというところの判断はできず、私個人としては Remix は結構良さそうなフレームワークであり、もう少し深ぼって触ってみるのも面白そうだなと感じました。

最後に、ここまで長々と Remix について書いてきましたが、個人的な解釈で書いている箇所などもあったりします。できるだけ公式に書いてある内容を参考にまとめましたが、間違っている箇所などあれば優しく指摘していただけると嬉しいです。

参考

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

https://zenn.dev/mackay/articles/123c29f46d213c

Discussion

Honey32Honey32

失礼します。細かいところで恐縮ですが、Controlled Component を使わずに DOM の機能を使ってフォームを作成できるのは Remix 特有ではなく React の機能なので、Next.js でも使えます。

ちなみに、App Router では actions に相当する機能が追加されています。

https://nextjs.org/docs/app/api-reference/functions/server-actions

const PokemonCreatePage = (props: { poke: Response }) => {
  const { poke } = props;

  const router = useRouter();
  const pokemonId = router.query.pokemonId as string;

  const handleSubmit = useCallback(async (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();
    const data = new FormData(e.currentTarget);
    const name = data.get("name");
    await updatePokemon(pokemonId, name);
    router.push(`/pokemon`);
  }, [name, pokemonId, router]);

  return (
    <form onSubmit={ handleSubmit }>
      <p>
        <span>Name</span>
        <input
          defaultValue={ poke.name }
          aria-label="name"
          name="name"
          type="text"
          placeholder="Name"
        />
      </p>
      <button type="submit">Save</button>
    </form>
  );
}

export default PokemonCreatePage;
つちのこつちのこ

いただいた2点のご指摘部分、修正と注意書きの追加を行おうと思います!
ご指摘いただきありがとうございます!!🙇