Remix入門: フロントエンドもバックエンドも爆速開発を実現する次世代Webフレームワーク
こんにちは!Acompanyのマッケイです!
この記事は Acompany5周年アドベントカレンダー 11日目 の記事です。
今回はAcompanyのプロダクト開発でも活用しているRemixを開発環境で使ってみた所感を書いていこうと思います。
Hello,Remix
Remixは、Reactをベースとしたフルスタックフレームワークです。
Reactを魔改造して色々できるようにしようぜ、という昨今のモダンフレームワークに習うように、RemixもReactに厚化粧をした"React"フレームワークです。
書き心地はそのままReactですが、気づいたらサーバーサイドのコードを書いており、気づいたらデータベースをいじっているというなんとも不思議な経験ができるフレームワークです。
フルスタックフレームワークを使っているというよりは、Reactで開発しながら、サーバーサイドの処理も同時に書けるのがRemixです。
モダンフロントエンドという荒波の中で存在感を放つRemixの、実際の開発現場で使ってみた所感をRemixの入門を織り交ぜながら説明します。
Remix導入の参考にしていただければと思います。
Remixの哲学
Remixの哲学を一言で表すと、「Web標準に忠実に」です。
データを取得したいですか?
→ Web Fetch APIを使います。
データのミューテーションを行いたいですか?
→ Formが全て行ってくれます。
サーバーに状態を持たせたいですか?
→ CookieとSessionの出番です。
Remixの取り扱いが上手くなると、Webの取り扱いが上手くなります。
Remixで取り扱う概念のほとんどは、25年前のインターネットバブルの頃から脈々と受け継がれてきた技術インフラです。
Remixの革新は、このカラカラに枯れ切った技術インフラの上に、Reactを初めとする多くのモダンパッケージと開発体験を融合させたことにあります。
しかし、心配することはありません。
SPAに飼い慣らされた私のようなフロントエンドエンジニアであっても、難なくバックエンドの開発を始めることができます。
フロントエンドを主戦場とするエンジニアでも、今日からフルスタックエンジニアの仲間入りができる。
それがRemixなのです。
「MDNは友達」が合言葉です。
Remixのアーキテクチャ
Reactの革新の一つは、「単方向データフロー」をWebの世界に取り入れ、View,Action,Stateの見通しを良くしたことです。
階層の一番上のコンポーネント (FilterableProductTable) が、データモデルを props として受け取っています。データがトップレベルのコンポーネントからツリーの下の方にあるコンポーネントに流れていくため、この構造を 単方向データフロー (one-way data flow) と呼びます。
データは常に、アプリケーションを上から下に向けて流れるため、コードの記述と理解が容易になります。
この考え方はReactだけに限らず、昨今のモダンフレームワークでも取り入れている概念であり、"State"によって"UI"が決定されると言う意味で、下記のようなシンプルな式で表現されます。
Start thinking declaratively:Flutter
何らかの方法で(例えばActionを用いて)Stateを変更することによって、Viewが再レンダリングされます
このメンタルモデルを使用して、Reactでも数多くの状態管理ソリューション (Context API, Redux, Recoil)が作成されてきました。
アプリケーションが、本当の意味でReactの中で閉じている間は、このメンタルモデルで問題ありませんでした。
しかし、アプリケーションにはデータベースや外部APIとの接続などが存在し、常にデータはReactの外に置かれます。
Reactでは、外部に永続化されているStateを毎回自身のState管理システムにコピーしてから利用する必要があります。
もちろん、コピーしたStateをクライアントで変更する場合は、クライアントのState変更と外部Stateの変更を、あんなことやこんなことをして二重管理する必要があります。(ああなんと嘆かわしい😇)
Reactのあらゆる状態管理システムは、クライアント上の状態の管理には役立ちますが、クライアントとサーバー上の状態を効果的に管理することには、無力です。
ここで、Remixの登場です。
One of the primary features of Remix is simplifying interactions with the server to get data into components.
(Remixの主な特徴の1つは、データをコンポーネントに取り込むためのサーバーとのやり取りを簡略化することです。)
Remixは、データフローをネットワーク全体に拡張して、サーバー(State)からクライアント(View)へ、そしてクライアント→サーバー(Action)を介してサーバー(State)に戻るという、真に単方向データフローを実現しています。
リモートの状態をクライアントにロードしてきて、状態を変更する場合は、あくまでリモートの状態を変更し、その変更を再フェッチすることでクライアントのビューを再レンダリングします。
煩わしいState管理を行う必要はなく、ビューが参照するStateは、常にリモートのサーバーで管理しているStateと同期が取れた状態で参照するができ、バグが入る隙間を圧倒的に少なくします。
サーバーに保存するまでもないクライアントのローカルなStateは、Reactの状態管理ライブラリを用いて管理することが可能です。
もちろん、これら全てのアークテクチャを開発者が実装する必要はありません。
Remixの丁寧な抽象化のおかげで、開発者はわずかなコードを記述するのみで、サーバーのStateをクライアントに渡し、クライアントからのアクションをサーバーで処理を行うことができます。
それでは、次章で具体的なRemixの開発体験について記述します。
Remixでの開発
Remixでの開発フローは大きく分けて3つです。
- Viewの定義
- Loader(またはAction)の定義
- データベースへの保存
Viewの定義
RemixのViewとは、つまりそのままReactのことです。
jsx
/tsx
を用いてコンポーネントを構築することはReactと変わりません。
Remixでは、ファイルベースのルーティングシステムがビルドインされているため、Reactの時のようにルーティングライブラリをインストール、整備する必要はありません。
app/route
ディレクトリ以下に作成したディレクトリやファイルが、そのままアプリケーションのURLとして構築されます。
これは、Next.jsとほとんど変わりません。
以下のコードで、ログインページのViewを/login
URLパスで表示することができます。
export default function Index(){
return(
<Form method="post">
<Input name="email" />
<Input name="password" type="password" />
<Button type="submit" />
</Form>
)
}
Loader(またはAction)の定義
Remixには、サーバー処理を記載するためのloader
/action
関数が用意されています。
この関数は、クライアント↔︎サーバー
のやり取りを行うための関数です。
loader
関数では、サーバーのデータ(State)をViewに渡すためのデータ(State)を定義し、loader
関数の返り値をViewで受け取ることが可能です。
import type { ActionArgs } from "@remix-run/node";
import { redirect, json } from "@remix-run/node";
import { useLoaderData } from "@remix-run/react";
export const loader = async ({ request }: LoaderArgs) => {
const isAuth = await isAuthenticated(request);
// 既にログイン済みであれば、/auth/callbackにリダイレクト
if (isAuth.logged) throw redirect("/auth/callback");
return json({
logged: isAuth.logged,
error: isAuth.error
});
};
export default function Index() {
// loader()の返り値を取得するHooks
const { error } = useLoaderData<typeof loader>();
return (
<Form method="post">
<RootError error={error} />
<Input name="email" />
<Input name="password" type="password" />
<Button type="submit" />
</Form>
);
}
loader
関数は、RESTfullでいうGET
に相当する関数であり、app/route/*
のファイルでexport const loader
をすることで、そのURLパスにGET
APIエンドポイントを定義することができます。
loader
関数は、Remixがうまく抽象化を行なっているおかげで、まるでWeb Fetch APIを扱うかのごとく、サーバーサイド処理を記述することができます。(return json
はまんまfetch().((res)=>res.json())
の書きぶりです)
もちろん、loader()
内で、fetch関数を用いたデータフェッチやResponse
インスタンスでの返却、URLパラメータの処理など、一般的なサーバーの処理にWeb標準な処理を共存させることも可能です。
export const loader = ({ request }: LoaderArgs)=>{
// クッキーの読み込み
const cookie = request.headers.get("Cookie");
// fetch data
const res = await fetch("https://fetch.com")
// クエリパラメータの取得(?q=)
const url = new URL(request.url);
const query = url.searchParams.get("q");
// fetch("https://any/path").then((res)=>res.json()) と同じ
// json()の返り値が、クライアントに送信される
return json({ any: "thing" });
// const res:Response = fetch("https://any/path") と同じ
// Responseの値が、クライアントに送信される
return new Response(JSON.stringify({ any: "thing" }), {
headers: {
"Content-Type": "application/json; charset=utf-8",
},
});
}
action
関数では、クライアントからのActionを受け取り、Stateの変更などを行うサーバーサイドの関数です。
クライアントのPOST
リクエストを受け取り、Bodyのデータを読み取ることが可能です。
import type { ActionArgs, LoaderArgs } from "@remix-run/node";
import { redirect, json } from "@remix-run/node";
import { useActionData, useFetcher, useLoaderData } from "@remix-run/react";
import { authenticate, isAuthenticated } from "~/servers/auth.server";
export const loader = async ({ request }: LoaderArgs) =>{
// loader処理
}
export const action = async ({ request }: ActionArgs) => {
// Formのデータを取得
const formData = await request.formData();
const email = String(formData.get("email"));
const password = String(formData.get("email"));
const { error } = await authenticate(
request,
{ email, password },
// 認証に成功したら、/auth/callbackにリダイレクト
"/auth/callback"
);
return json({ error });
};
export default function Index() {
// loader()の返り値を取得するHooks
const { error: loaderError } = useLoaderData<typeof loader>();
// action()の返り値を取得するHooks
const { error: actionError } = useActionData<typeof action>();
return (
<Form method="post">
<RootError error={loaderError} />
<Input name="email" />
<Input name="password" type="password" />
<ErrorField error={actionError} />
<Button type="submit" />
</Form>
);
}
Remixでサーバーサイドにリクエストを行うのに、fetch
もaxios
も必要ありません。
<form />
に<input />
などのフィールドを用意して、<Button type="submit" />
を行うだけで、クライアントのデータをサーバーに送ることができます。
サーバー側でも、受け取ったデータを簡単にパースして値を取り出すことができます。
何とも古き良きクライアント/サーバー方式の処理フローをしています。
残念ながら筆者はエンジニア人生をReactから始めた人種なため、懐かしさに浸ることはできませんが...
もちろん、POST
だけでなく、PUT
やDELETE
メソッドも使えるようので、RESTfullっぽい処理も記述可能です。
export const action = ({ request }: ActionArgs)=>{
switch (request.method) {
case "POST":
return postFn(request);
case "PUT":
return updateFn(request);
case "DELETE":
return deleteFn(request);
default:
return null;
}
}
クライアント側では、<form method={"POST" | "PUT" | "DELETE"} />
で制御します。
データベースの保存
View, loader/actinができたら最後はDBへの保存です。
MVCに精通する方々は、RemixがV
とC
を提供するフレームワークであることにお気づきになるかもしれません。
Remixは、ViewをReact、Controllerをloader/action関数を用いて構築ができますが、Model部分をどのように構築するかは開発者に委ねられます。
生SQL書くも、ORマッパーを使うも、外部APIを使うも自由に選択が可能です。
ただ、サーバーサイドでコードを動かすことができるという一点において、Reactよりも柔軟で堅牢なState管理を行うことができます。
とはいえ、何もとっかかりが無いのもそれはそれで、開発に困るのも事実であり、
Remixは、Remix Stacksで、数百万人のユーザーにサービスを提供する大規模かつ高速なプロダクショングレードのアプリケーションを想定したテンプレートプロジェクトを用意してくれています。
例えば、Blues Stackでは、PostgreSQL + Prismaを使用しています。
私も実際の開発環境では、このBlues Stackの構成を継承して開発を進めています。
Prismaは非常に使いやすいNode.jsのO/Rマッパーであり、普段はSQLをいじらない筆者であってもすんなりとDBの操作をすることが可能です。
Prismaを使うと、下記の3ステップでDBのスキーマ構築と操作が可能になります。
-
schema.prisma
の記述 - マイグレーション
- クライアントコードの記載
schema.prisma
ファイルは、TypeScriptでtype
やinterface
を書くかのようにDBのスキーマを定義できます。
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
generator client {
provider = "prisma-client-js"
}
model User {
id String @id @default(uuid())
name String
email String @unique
createdAt DateTime @default(now()) @map("created_at")
post Post[] //
@@map("user_account") // Schemaで利用する名前とDBのカラム名を変更する
}
model Post {
id String @id @default(uuid())
name String
content String
ownerId User @map("owner_id") @relation(fields: [ownerId], references: [id])
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@map("post")
}
スキーマを変更したらマイグレーションを行い、DBに変更を適用します。
npx prisma migrate dev
スキーマの内容をPrismaのクライアントライブラリにも変更を適用します。
npx prisma generate
これで、DBのスキーマの構築とアプリケーションコードでのDBの操作が可能です
つまり、loader
/action
関数でDBへの保存が可能になります。
epxort const loader = async ({ params }: LoaderArgs) => {
const userId = params.userId // URLの動的パスの値を取得
const data = await prisma.user.findUnique({
select: {
id: true,
name: true,
email: true,
},
where: { id: userId },
});
return json(data)
}
export const action = async ({ request }: ActionArgs) => {
const user = currentUser(request)
const formData = await request.formData()
const name = formData.get("name")
const content = formData.get("content")
const data = await prisma.post.create({
name,
content,
ownerId: user.id
})
return json(data)
}
この一連の開発を通して、Remixで下記のアーキテクチャを実現します。
Remixの恩恵
State管理からの解放
Remixのアプリケーションで、State管理のための追加のライブラリは必要がありません。
Remixで取り扱うStateのほとんどは、サーバーサイドから供給されます。
Reactは受け取ったStateをコンポーネントに流す込むだけでよく、本当の意味で、インターフェース(コンポーネント)の管理を行うライブラリに集中することができます。
また、Remixでは、階層構造になったRoute
内で、親Routeから子RouteへのStateの共有が可能です。
例えば、/app/
というURLのStateを、/app/home
からアクセスできます。
組み込みで用意されたuseRouteLoaderData関数を使うことで、簡単に任意の親RouteのStateにアクセス可能で、実質的にグローバルなState管理を代替することができるのです。
アプリケーション全体で使うデータをあらかじめ親Routeのloader
で読み込んでおくだけで、全ての子Routeからアクセス可能です。
TypeSafeなクライアント/サーバー処理
Remixは、デフォルトでTypeScriptに対応しています。
loader
/action
から返される全ての値は、クライアント側と型定義を共有することができます。
クライアントでは、全ての値に型がついた状態で取り扱うことができます。
Remixではこれを、tRPC
やGraphQL
といったものに頼ることなく、シンプルな構成で実現しています。
サーバーサイドの開発体験の向上
Reactに慣れたエンジニアは、全てのコードをクライアント上に置こうとします。
しかし、現実のアプリケーションを開発しようとすると、サーバーサイドのコードを避けて通ることはできません。
認証/認可、DBへの保存、セッションの管理、上げだすとキリがありません。
Remixのコアコードはサーバーサイドです。
そして、Remixはサーバーサイドのコードを、まるでReact(つまりクライアントサイド)で書いているかのように記述することが可能です。
開発者は、Remixによってうまく抽象化されたエコシステムの中で、サーバーサイドとクライアントサイドを相互に行き来しながらコードを記述することができます。
コアコードはサーバーサイドにあるのに、開発体験はReactで開発しているような気分になる、それがRemixというフレームワークです。
パフォーマンス向上
昨今のWebパフォーマンスの向上とは、いかにしてクライアントに送信するデータの量を減らせるかに神経をすり減らしています。
Reactの莫大な量のコードをロードする仕組みを解決するために、SSR
やSSG
といったものも開発されました。
Remixでも、クライアント側のロード時間を削減するためにあらゆるチューニングがされています。
RemixはSSR
をサポート(むしろSSRしかサポートしていません!)しているため、サーバー上で実行可能なJSコードをクライアントに送信することはありません。
「Web標準に忠実に」という言葉の通り、HTMLやCSS,WebAPIで実現できるものにわざわざJSコードを使うこともありません。
もちろん、コンテンツのキャッシュにも気を使っており、毎回SSRを行なってしまうという特性にも、エッジコンピューティングというアプローチで解決を図っています。
Remixの辛いポイント
統一的なAPIエンドポイントを作るのに苦労する。
今時点で、最も頭を悩ませているのが、何度も同じ処理を行うコードをうまく共通化する方法が見出せていないことです。
Remixは、コードのコロケーションを非常に意識したコードを書くことになります。
前述した通り、loader
/action
はそのエンドポイントを使いたいコンポーネントと同じファイルからエクスポートされます。
その場所でしか使わない処理であればこの方法でも問題ないのですが、アプリケーションを開発していく中では、いくつかの場所で同じような処理を呼び出したいケースは結構な頻度で発生します。
その場合、どのエンドポイントを真のマスターエンドポイントにするのか、という問題に悩まされます。
もちろん、app/route
下であれば、自由にURLを生成できるので、Viewを持たないAPI
のみのエンドポイントを生やすことも可能です。
いずれにしても、どのエンドポイントからどのようなCRUDができるのかを管理する方法がないため、開発者が一生懸命に整合性を保ちながらコードを書く必要があります。
このコードを共通化すると言う点に関しては、Remixのコンポーネントと処理は別々で管理しなければならないと言うルールが、裏目に出ているなと感じます。
これに関しては、私の環境ではまだコード量が少ないため、同じようなコードをいくつかの箇所で転載する方法で対応しています。
今後、コードが肥大化していく中で、良い解決方法を模索する必要がありそうです。
まとめ
今回は、Remixの紹介と、プロダクト開発の環境で使ってみた所感をまとめてみました。
Remix自体は非常に洗練されたフレームワークであり、アプリケーションを開発する上で十分な機能を持っている感じます。
個人的には、Next.js一強時代に、突如として全く異なるアプローチでNext.jsとガチンコを始めていたRemixを応援したい気持ちではあります。
SPAの時代から、プログレッシブリー・エンハンスド・シングルページアプリ(PESPAs)の時代に突入を始めているWeb界隈において、新たなメンタルモデルを実現するフレームワークで遊べることはエンジニアとして非常に興奮を覚えます。
Remixは、PESPAsをリードするフレームワークとして、今後もどんどん成長を続けていくフレームワークになると思います。
ぜひ皆さんも、Remixによる快適フロント開発を体験してみてください。
最後になりますが、ここまで読んで頂けた方への些細なプレセントとして、Remixの開発をすぐに始められるようなブートストラップテンプレートを共有して終わりにしようと思います。
技術スタックで検索すると、その技術スタックを用いたRemixアプリケーションを構築するためのテンプレートをゲットすることができます。
それでは、混沌としたモダンフロントエンドの海原に漂うエンジニアの皆さんが快適なフロントエンドライフを過ごせることを祈りながら、締めさせてもらいます。
Discussion