Remix初心者がRemixを使い始めてみたのでその感想

Page content

はじめに

Reactベースのフルスタックフレームワーク「Remix」を使い始めてみた。まだGetting Startedに毛が生えた程度のことをやってるのだが、とりあえず今の所の感想をまとめておきたい。なお、Remixに関しては前述の通りド初心者だが、よく対比させられるフレームワークであるNext.jsに関しては趣味では4~5年触っており、この辺りのフレームワークでよく出てくるSSG (Static Site Generation)やSSR (Server Side Rendering)とかのキーワードに関する知見は一応持っている。

なぜ今Remixか

単純にNext.jsに飽きてきたからである。 Next.jsは最古で言うと多分v10くらいから触っている。ここ数年、趣味で作るWebアプリでは基本的にはNext.jsを脳死で採用してきた。これが一番使いやすいからだ。今でも特に何もなければ基本的にはNext.jsを使う。実際、直近だとv15で作った「ストレイテナーWeb通知くん」という趣味プロダクトをリリースしている。

ただ、Next.jsは、v13でApp Routerが入ってきてちょっと取っつきにくくなったと感じている。それ以前のバージョンのNext.jsはPages Routerという仕組みを使っており、個人的にはこの時代に色々作ったというのも背景にはあるが、Pages Routerの考え方や仕組みに慣れすぎていてこれが染みついており、App Routerにどうにもうまく馴染めない。使いこなしてるっていうより使われてる(仕方なくApp Routerで実装してる)という気がしており、書いててあまり面白みを感じなくなってきた。長く付き合ってるカップルの倦怠期みたいな感じで、まあ単純にNext.jsに飽きてきたんだろう(最新の思想に自分が合わなくなってきたんだろう)と自分なりに納得するに至った。

そんな中、前からWebフレームワークとしてNext.jsとの対比の文脈でRemixをよく目にしてきたので、これには個人的に興味があった。今回意を決してNext.jsではなくRemixでちょっとした(個人用の)Webアプリを作ろうと思いたち、開発に着手した。

今の所の感想

良いです。 Next.jsと比べて良い意味でシンプルで、わかりやすい。少なくともApp Routerに比べれば断然取っつきやすい。不満がないわけではないが、今の所まあ許容できるレベル。以下、ポイントごとに。

実装箇所と内容がシンプルでわかりやすい

読み込み時の処理はloader()、送信時の処理はaction()、レンダリングはexport default functionで定義したReact Component、という3つのルールでほぼ語りつくせる、というのが個人的に非常にわかりやすい。また、loader()にしてもaction()にしても、ページ側にJSONオブジェクトをreturnして、React Component側ではuseLoaderDatauseActionDataをGenericsつけて呼び出せば、型もついて扱えるというのも実装しやすくて良い。(以下はloader()の例)

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

export async function loader() {
    console.log('test.tsx loader');
    return {test:'this is /test/route' };
}

export default function Test() {
    const data = useLoaderData<typeof loader>();
    return (
        <div className="bg-sky-400">
            <h2>this is /test</h2>
            <p>test: {data.test}</p>
            <Outlet />
        </div>
    );
}

かつ、「loader()action()もサーバー側で動く(SSRである)」というのが分かりやすい。Next.jsだと、「この処理サーバーで動くんだっけ、それともクライアントで動くんだっけ」みたいに混乱することがたまにあったが、そういう混乱が生じる余地がそもそも存在しない。絶対サーバー処理になるというのは余計な事を考えなくて済む。

Flat-Routes(フラットルート)は個人的には(現時点では)あまり好きではない

RemixはFlat-Routes(フラットルート)という考え方を採用している。これは(私の理解では)/app/routes/配下に全ファイルぶわーっと並べましょう、 というものだ。例えば以下のような感じになる。

603 May 14 17:10 /app/routes/_index.tsx
513 May 15 16:59 /app/routes/test.$param.tsx
663 May 15 19:23 /app/routes/test.auth.tsx
707 May 15 19:28 /app/routes/test.test2.tsx
780 May 15 17:28 /app/routes/test.tsx

例えば、上の例だとhttps://example.com/testというrequestパスは/app/routes/test.tsxで受けて、https://example.com/test/test2というrequestパスは/app/routes/test.test2.tsxで受けて、という感じになるが、/test/test/test2という階層の異なる2つのrequestパスを、実装上は/app/routesの直下で同様に、ディレクトリではなくファイル名を分けることで書きわけることになる。これが個人的にイマイチピンと来ておらず、URLのパス階層は実装上もディレクトリで分割されてたほうが分かりやすいのだが(この例だと/test/app/routes/test/_hogehoge.tsxで、/test/test2/app/routes/test/test2/_hugahuga.tsxで、それぞれ受ける)、Remix(のデフォルト)ではそうなっていない。

ただ、これは多分、Next.jsに慣れてるせいなんだろうと個人的には考えている。Next.jsはURLのrequestパスを、ソース上はそれに相当する形で同様のディレクトリを掘って実装していたので、その考え方が染みついているんだろう。なので慣れの問題のような気がする。実際、「こういう場合にディレクトリ分けたいか?(その明確なメリットは何だ?)」と自問すると、単に「好みの問題」というくらいしか答えを捻りだせない。

それに、Remixは、このページの冒頭で、「どんなルーティングを採用したとしても不満は出る」といった前置きをしていて、色々あってこの「Flat-Routes」という考え方に至ったのだろうと思えば、利用者としてはまずそこを尊重して利用したい(してみよう)と思うに至った。それに反したい強い意図も目的もないし。最終的にどうしても軌道修正が必要になったら(そこまでいくとも思えないのだが)また考えることにする。

ちなみに、一応Remixでも、ディレクトリ分割して実装することは可能になっている。ここに例を挙げてくれている。

複数階層のページが作りづらい

これは↑の続きというか関連である。Remixはおそらくその仕様上、複数階層のページ、たとえば/dashboard/dashboard/hoge/dashboard/hoge/hugaみたいのは作りづらいというか、やってもいいんだがこの場合のページの作り方がちょっと限定される傾向にある。というのも、子ページ(上で言うと/dashboard/hoge/dashboard/hoge/huga)のページレイアウトを描画させるためには、その親ページ側でOutletの記述が必須だからである。親ページ側にOutletがない場合、子ページへのアクセスでは親ページ側のレイアウトが表示されることになる。たとえばdashboard.tsxOutletがないと、/dashboard/hoge(子ページ)へのリクエストで表示されるのはdashboard.tsxexport default function ...のReact Componentであり、dashboard.hoge.tsxexport default functionのReact Componentではない。/dashboard(親ページ)と/dashboard/hoge(子ページ)で画面表示が変わらないのである。実質的に/dashboard/hogeが無意味なページと化してしまう。

親ページにOutletがあれば/dashboard/hogeのリクエストにおいてdashboard.hoge.tsxのReact Componentのレンダリング処理が呼び出されるが、これはあくまで「親ページのレンダリングの中のOutletの部分に子ページのレンダリングを埋め込む」というものであって、子ページ単独の表示を意味していない。なので「子ページ単独の表示」というのはRemixでは実質的に実現できない。例で言うと以下のような感じである。

// dashboard.tsx
export default function Oya() {
  return (
    <div>
      ここは親ページです
      <div>
        ここから子ページです↓
        <Outlet />
      </div>
    </div>
  )
}
// dashboard.hoge.tsx
export default function Ko() {
  return (
      <div>
        これは子ページです。
      </div>
  );
}

dashboard.hoge.tsxKo()のレンダリングは/dashboard/hogeへのリクエストで呼ばれるわけではなく、dashboard.tsxOya()Outletから呼び出される形で初めて発動するので、/dashboard/hogeへのリクエストでそのページ単独のページレイアウトKo()だけを実行することができない(必ず親ページのレイアウトとセットになる)。たとえば、「商品検索機能」みたいのを作ろうと思ったとき、商品検索画面(/search)→検索結果一覧(/search/result)くらいまでならまだこのルールでもなんとかなりそうだが、その後の「商品詳細」画面みたいのを作ろうと思ったとき、URL的には/search/result/(商品ID)みたいにしたくなるが、この場合上のルールに従うと「検索結果一覧の中に商品詳細が表示される」ようなつくりにせざるを得なくなる。まぁ親ページ側でOutletをどう呼ぶか(どこにOutletを仕込むか)次第なので、工夫次第ではやってやれなくもなさそうだが、個人的にはこのルールで複数階層の画面を構成するのには無理があるなと思っている。Remixは深い階層で画面描画するのに適してなさそうな(そういう考え方でアプリを作る思想にそもそもなっていない)雰囲気を感じる。こういうパスの切り方(/search->/search/result->/search/result/(商品ID))が個人の感覚としては直感的に感じるもんで、そのためRemixのこの思想というか作りとは正直、若干相容れない。階層ページに関しては多用するのを避け、深くても1段階まで(aaa.bbb.tsxくらい)に収めるように作ったほうがよさそうだなと思った。

ただまぁよく考えると、別にそんな深い階層のページを作りたいと思ってたわけでもないし、それが絶対に必要ってな理由があるわけでもないのだ。これはこれで作り方をシンプルにさせる可能性もあるし、悪いことだけでもないんだろう。Remixのこの仕様は、あんまり使い勝手が良さそうだとは個人的には感じないが、まあこういうモンだと思って一旦割り切って使ってみることにする。

子ページのリクエストで親ページのloader()が自動実行される

たとえばtest.tsxtest.test2.tsxは、実際のリクエストパスとしてはそれぞれ/test/test/test2に対応して受け付けることになるが、この場合、/test/test2のリクエストにおいて、/test2からみたら一つ親に当たる/test、つまりtest.tsxloader()が自動的に実行される。これはOutletを親側(test.tsx)で呼び出しているかどうかに関わらない。どうもRemixの仕様みたいだ。「逆に/test/test2のリクエストではtest.tsxloader()は実行してほしくない」という制御が(基本的には)できなくなっている(ある子ページのリクエストに際しては、親ページのloader()は問答無用で実行されてしまう)。感覚的には、これは↑の話(Outletがないと子ページがレンダリングされない)とはほぼ真逆の動作である。

個人的には↑の話とは逆にこれはこれで良いと思っていて、特に感じている利点は、「あるリクエストパス以下のページについては全ページ認証済とする」 みたいなことが割と簡単に実現可能であることだ。例えば_auth.tsxみたいのを作っておいて、これのloader()に「認証済みかどうかチェックする処理(認証済でなければログイン画面にリダイレクトさせる)」みたいな処理-便宜上ここではそれをcheckAuthenticatedという関数で定義したとして-を組んでおいて、

// _auth.tsx
export async function loader({request}:LoaderFunctionArgs) {
  const user = await checkAuthenticated(request);
  return {user:user}; 
}

認証が必要なページ群は先頭に_authのプレフィックスを付けてファイルを定義しておけばいい。例えば以下のようなファイル群

_auth.tsx
_auth.dashboard.tsx
_auth.hoge.tsx
_auth.huga.tsx

は、/dashboard/hoge/hogeのリクエストパスを提供するが、そのどのページに関してもまず_auth.tsxloader()が実行されるので、各ページのloader()でいちいちcheckAuthenticatedを呼び出さなくても、自動的に認証済判定されて、認証していなければログイン画面にリダイレクトされるように動く。未認証のユーザーがURLのパスを直打ちして入ってきても認証で弾かれるようにできる(それを各ページでいちいち実装しなくてよい)というわけだ。Next.jsでlayout.tsxに判定用の処理を組み込んでおけば全ページに対して自動的に認証済判定が入るというのを昔やったが、それと似ている。認証判定はあくまで例で、こうすることで配下のページの読み込み時に共通処理を組み入れることができる、というのがこの話の本懐になるだろう。

ただ、親ページのloader()の実行結果は、子ページの読み込み時には使えない。子ページでuseLoaderDataで取り出したときに取得できるのは、あくまで子ページ側のloader()の実行結果だけである。上の例で_auth.tsxreturn {user:user}; とUserオブジェクトをreturnしてるが、子ページ_auth.hoge.tsxloader()が別のオブジェクトのreturnをしている場合、子ページのloader()のreturnが使用される。

// _auth.hoge.tsx
export async function loader({request}:LoaderFunctionArgs) {
  const hoge = 'hoge'
  return {hoge:hoge}; 
}
export default function Hoge() {
  const data = useLoaderData<typeof loader>();
  return (<div>data: {data.hoge}</div>); // "data: hoge"となり、親ページ_auth.tsxのuserオブジェクトは取れない
}

なので、上の「認証済ページ」の例をとってみても、たとえば「認証済ユーザーのユーザーIDからトランザクションを取得」みたいな処理が必要になる場合、「親ページで処理しているからいいや」というわけにはならず、結局子ページで同じような処理が必要になる。というか色々調べてみると、loader()というのは本来そういうものだそうなので(/test/test/test2は何の関連もない別ページであり、個々のloader()もそれぞれのページ用に独立している)、そうなるとこの「子ページへのリクエストで親ページのloader()が実行される」というのがそもそも何か偶然の産物というか、Remixが意図して行っているものかどうか若干、怪しい気もする。一応2025年5月時点ではこの動きが個人的なユースケースに利用できそうなので活用させてもらうつもりである。 

なお、今回、認証にAuth0を使おうと思っていて、Auth0をRemixに組み込む場合の実装が必要になったが、これについてはこちらの記事が非常に参考になった。備忘録も兼ねてここでリンクしておく。

バージョンサポートが不明

見出しの通りだが、バージョンサポートの情報が不明というか、よくわかってない。Next.jsはどんどん進化していって、その分昔のバージョンのEOLも速いのだが、市場に浸透している分この辺はしっかりドキュメントに明記されている。→Next.js support-policy

でもRemixは探してもそういう情報が見つけられない。2025/5/17時点の最新はv.2.16らしいが、リリースサイクルはどれくらいか、昔のバージョンサポートがどこまでされるのか、今のバージョンのEOLはいつか、など、記述がない。そもそも「サポート」とかそういう概念自体存在していない可能性もあるが、その辺り含めて不明。

ただまぁよく考えてみれば、絶賛進化中のソフトウェアなんて大体そんなもんだし、そもそも個人の趣味で使うようなものに「バージョンサポート」なんてEnterpriseユーザーのような偉そうなこというのもおかしな話なのだ。この辺は使ってるうちにそのうちわかってくるんじゃないかと思っており、現時点ではそれほど強く危惧していない。破壊的変更みたいなの入れるのは勘弁してほしいなーとは思っているが。

Remix、どこにホストする?

個人的にはできればCloudflare Workersにホスティングしたいんだが、以下の理由から今の所はちょっと様子見で、別の所にホスティングするつもりである。

  1. Cloudflare Workersは、Freeプランだとデプロイサイズ3MBといった制限がある。最終的にはPostgresqlとの接続を実現するためPrismaを入れたいと思っているのだが、これを入れるとサイズ超過する(のでFreeにはデプロイできない)というのも報告されている。それの対策のため、PrismaをCloudflare Workers(のようなエッジランタイム)で使用する場合、@prisma/pg-worker@prisma/adapter-pg-workerといった軽量版のPostgresqlアダプターがPrismaから提供されているが、直近これに破壊的変更があって苦労した思い出があり、使用するのに踏み切れないでいる。
  2. Cloudflare Workersはエッジランタイムなので、Node.jsのAPIがまるまる完全に動作するわけではない。nodejs_compatフラグを付ければbufferとかcryptoとかは動作するようにはできるが、それでも完全ではない。別にこれらのAPIが使いたいという明確な目的があるわけじゃないんだが、開発中に入れる色々なパッケージ群が、実は裏でこの辺のAPI使っていて、結局Cloudflare Workers上では動かない、といった事態は避けたいし、また実際の所開発作業自体はそんなの気にせずに行いたいという思いもある。
  3. 残念ながら検索する限りでは実例がまだ少なく、なんとなく色々苦労しそうだなという感想がある。そこに苦労する(時間をかける)のは開発の本旨ではないので、できれば避けたい。Remix on Cloudflare Pagesなら割と見かけるが、直近Cloudflareの人が「これからは特別な理由がない限りPagesじゃなくてWorkers使ってください」と言っているのを見かけており、それを読んでまでPagesにデプロイしたいと思わない。実際、SSRしかないRemixをPagesにデプロイするというのも、個人的にピンとこない(フロントだけPages間借りしてるだけでその実裏はゴリゴリWorkersになるんだし、じゃあ最初からWorkersにすりゃいいんじゃないの、という印象)

将来的にはCloudflare Workersに乗っけたいな~という思いはあるが、現状は時期尚早という感想である。Remixは初めて使うってこともあるし、ここは正当に(?)普通にサーバー上というかそれっぽい環境上で動かしてみるか、と思い、今はHerokuを検討中である。こういうときにHerokuは本当に便利だ。EC2たてたりセキュリティグループ設定したりパッチあてたり再起動、、、とか煩わしいこと一切考えなくて済み、やることといったらGitにpushするくらいのモンである。やはりHerokuはいい。(個人の意見です)

おわりに

若干癖があるフレームワークだなという感想だが、Next.jsに比べてシンプルな実装ができそうで、個人的にRemixには可能性を感じている。少なくとも1つくらいは、Webアプリとして形にして運用するまでもっていきたいと思っています。