Remixで爆速開発できるディレクトリ構成のススメ
Hello, Remix.
こんにちは!Acompanyのマッケイです!
この記事は Acompany5周年アドベントカレンダー 28日目 の記事です。
今回はAcompanyのプロダクト開発でも活用しているRemixを開発環境で使ってみた所感を書いていこうと思います。
前回の記事で、Remixについてイントロダクションを書いたので、今日はより本格的なアプリケーションを作っていくためのディレクトリ構造を紹介できればと思います。
Remixアプリケーションを構築するための具体的なコードの例を示しながら、アプリケーション開発を始められる体制を整えていくための参考にしてください
ディレクトリ構成
フロントエンドの一大トピック、ディレクトリ構成については、残念ながらRemixフレームワークを使ってもなおまだ解決される見込みはありません。
しかし、Remixを用いると幾分か、見通しの通ったディレクトリ構造になる予感がしています。
公式のepic stack
を参考に、開発しやすいディレクトリ構成を考えてみます。
.
├── app
│ ├── components
│ │ ├── form.tsx
│ │ ├── error-boundary.tsx
│ │ └── loading.tsx
│ ├── models
│ │ └── model.server.tsx
│ ├── routes
│ │ ├── resources
│ │ ├── some-path
│ │ │ │ └── some-child-path
│ │ │ └── _layout.tsx
│ │ └── index.tsx
│ ├── utils
│ │ └── some-util.tsx
│ ├── entry.client.tsx
│ ├── entry.server.tsx
│ └── root.tsx
...
アプリケーションの軸となるapp/routes
から、どのようにディレクトリ構成を考えていけば良いのかを考えていきます。
routes
routes
ディレクトリは、アプリケーションのコアとなるアプリケーションコードで構成されます。
Remixのroutes
は、Next.jsと同様にファイルルーティングをサポートしているため、routes
以下のディレクトリがアプリケーションのURLパスに紐付けれます。
Remixのアプリケーションコードは、後述する様々なアイディアを取り入れることで、URLを"ドメイン"のスコープとして区切ることが可能です。
ある特定のドメインに依存するコードは、そのURLパス(つまりディレクトリ)に閉じるような構成を作ることができます。
アイディア①: Action/Loader
まずは、Remixのコア技術の一つであるaction
/loader
から見ていきます。
action
/loader
については、前回書いた記事で基本事項は説明しているので、どのように動作するかは割愛します。
Remixでは、バックエンドコードとフロントエンドコードを同じファイル内で取り扱うことが可能であり、action
/loader
は UIと密に結合しています。
これは非常に強力なコンセプトであり、アプリケーションはデータがフロントエンドどバックエンドでどのように取り扱われるかを完全にコントロールすることができるのです。
例えば、あなたがECサイトを構築しているとして、販売中の商品の一覧をhttps://your-host.site/products
というURL表示したいとします。
この場合、app/routes/products/index.tsx
は以下のようになります。
export const loader = () => {
const products = getProducts();
return json({ products });
};
export default function Index() {
const { products } = useLoaderData<typeof loader>();
return (
<ul>
{products.map((product) => (
<li key={product.id}>{product.name}</li>
))}
</ul>
);
}
プロダクトの一覧表示に関わる機能(UI, DataFetch)は、全てapp/route/products.tsx
に隠蔽されます。
他にも、「自分」の「商品」を「新しく追加」したい場合は、https://your-host.site/user/user-id/product/new
というURLに対して、app/routes/$userId/product/new/index.tsx
というディレクトリでユーザーごとに商品の追加が可能です。
export const action = ({ params, request }: ActionArgs) => {
// invariantという3rdパーティライブラリを用いると、URL内の動的パラメータをTypeSafeに管理できます。
invariant(params.userId, "UserIdがParamsに含まれていません.");
// request.formData()では、Formのバリデーションが煩雑になります。
// zodと組み合わせたFormバリデーションも後述します。
const formData = await request.formData()
const name: File|string|null = formData.get("name");
if (typeof name === "string" || !name) {
return json(
{
status: "error",
message: "商品名は必須項目です",
} as const,
{ status: 400 }
);
}
const data = await createProduct({
name,
createdBy: params.userId,
});
return json(data);
};
export default function Index() {
return (
<form action="POST">
<label>
<span>商品名</span>
<input name="name" required placeholder="商品名を入力してください" />
</label>
<button type="submit">新規作成</button>
</form>
);
}
ドメインに依存するコードは、ドメインのRouteにとりあえずぶち込んでさっさとアプリケーションのコア機能を完成させることに注力し、まずは、ユーザに価値あるコードを届けることにフォーカスする。
汎化が必要な機能やコードは、後から見返した時にRouteのコードを見ればすべてそこに「ある」という状況を作り出すことができるのが、Remixの強みだと感じます。
アイディア②: Nested Route
Remixのアプリケーションをうまく開発するために最も重要な概念はルーティングです。
ルーティングは先にも説明した通り、app/routes/
以下が実際のアプリケーションのURLパスにマッピングされます。
そのため、ディレクトリ構造をどのように作るかが、アプリケーションのUXに直結するのです。
Remixのルーティングは、一般的なファイルベースルーティングとほとんど同じように機能しますが、一部、Remix独自のルーティング機能が存在しています。
それが、この章のタイトルにもなっている、Nested Routeです。
百問は一見にしかず、ということでまずはRemixの公式ページをご覧ください。
Nested Routeのメリットは二つあります。
- レイアウトのネスト
- データレイアウトのネスト
コンポーネントレイアウトのネスト
Remixのルーティングは、Routeの階層を上から下へとレンダリングしていきます。
https://your-host.site/sales/invoices/102000
というルーティングがあった場合、下記のRouteがすべてURLに一致します。
- root.tsx
- routes/sales.tsx
- routes/sales.invoices.tsx
- routes/sales.invoices.$invoiceId.tsx
ユーザーがページにアクセスすると、コンポーネントは以下のようにレンダリングされます。
<Root>
<Sales>
<Invoices>
<InvoiceId />
</Invoices>
</Sales>
</Root>
つまり、Reactのchildren propsをルーティングレベルで行っているのがRemixのルーティングなのです。
Remixで用意されている<Outlet />
コンポーネントを利用すると、<Outlet/>
を配置した箇所に、ネストされたRouteのコンポーネントが配置されます。
これは、Reactのprops
で受け取ったchildren
を配置する開発体験とほぼ同等です。
データレイアウトのネスト
RemixのNested Routeは、コンポーネントのネストだけでなく、データもRoute内でネスト管理することが可能です。
sales
でloader
を通して取得したデータは、invoices
,$invoiceId
からもアクセスできます。
これは、フレームワークレベルでグローバルなステート管理が可能になることを示しており、ステート管理ライブラリを用いることなく、ステートを取り扱うことができるようになります。
Routeでのデータの受け取りは、useRouteLoaderData関数を使うことで、任意の親RouteのStateにアクセスをすることができます。
アイディア③: コロケーション
Remixの公式やサンプルプロジェクトなんかを見ると、主要なコードはroutes
下に配置されるような構成をよく見ます。
実際に、Remixのコントリビュータであるライアンさんもコロケーションについて言及しており、Remixには「コードを関連する場所のできるだけ近くに配置する」という考え方が含まれています。
個人的にもこの考え方は賛成であり、Reactの構成でよく見るcomponent
、hook
、pages
、api
という機能ベースでのディレクトリ構成は無駄にコードを分散させてしまい、コードベース全体の見通しが悪くなると思っています。
アプリケーション内で一回しか呼び出されない関数を、無駄に汎化させて分割することはあまり得策ではありません。
Remixでは、これをうまく行う方法が用意されています。
とはいえ、仰々しく書く必要はなく、シンプルにapp/routes/some/path/index.tsx
というページを構成するファイル内にすべてのコードを書けばいいだけです。
なんとも原点回帰よろしく、「使うところでコードを書けばいい」というシンプルな考えのもと、コードを管理しやすくすることができるのです。
もちろん誰も「数千行のソースコードが書かれたファイルを保守したいか」と言われて、Yesと答える人はいないでしょう。
コードの集約を行いつつ、見やすいコードを書く方法もRemixでは用意されています。
Remixでは、index.tsx
という名前で書かれたファイルには特別な意味が持たされており、index.tsx
が存在するパス階層では、index.tsx
のみがルーティングパスとなるというルールが存在します。
例えば、以下のようなディレクトリ構造がある場合、https://your-host.site/user
のみが有効であり、https://your-host.site/user/UserCard
,https://your-host.site/user/UserEditButton
はパスとして生成されません。
.
├── app
│ ├── routes
│ │ └── user
│ │ ├── UserCard.tsx
│ │ ├── UserEditButton.tsx
│ │ └── index.tsx
...
これにより、index.tsx
内のコードを分割して見やすくしながらも、ファイルのすぐ近くでモジュール化することことで、コードを探しにいく必要もありません。
各パス階層は、すべての依存するモジュールがパッケージ化されたミニアプリのように管理することが可能になります。
もちろん、/user
以下にさらにパスを増やしたい場合は、app/routes/user/edit/index.tsx
というようにパスを追加することが可能です。
test
やstorybook
も集約できます。
.
├── app
│ ├── routes
│ │ └── user
│ │ ├── User.stories.tsx
│ │ ├── UserCard.test.tsx
│ │ ├── UserCard.tsx
│ │ ├── UserEditButton.test.tsx
│ │ ├── UserEditButton.tsx
│ │ └── index.tsx
...
このトピックは、Remixのディスカッションで活発に議論されているので、気になる方は読んでみると面白いかもしれません。
アイディア④: Resouces Route
routes
ディレクトリ最後のアイディアはResouces Routeです。
- 各ページのログインフォームを配置することで、どこからでもログインできるようにしたい
- 画像アップロードは、汎用コンポーネントとして切り出したい
- ツイートへの「いいね」は、表示されるすべてのツイートコンポーネントで押せるようにしたい
なんだかんだ、アプリケーションを作っていくと、汎用的に使いわましたい機能が出てくるのが常です。
そんな時に、便利な機能がResouces Routeです。
Resouces Routeパス自体は、「コンポーネントをレンダリングしないRoute」であり、action
/loader
のみをファイルからexportするだけで実現可能です。
つまり、ただのAPIエンドポイントをRemix Route上に構築できるというのが、Resouces Routeです。
Resouces Routeは、app/routes
のどこの階層にも作ることができますが、私はapp/routes/resources
にパスを作成し、/resources
以下にResouces Routeを配置していきます。
Resouces Routeには、再利用性を高める処理を記述していきます。
例えば、ログイン機能を各ページで利用したいケースの場合、Resouces Routeで以下のようにログインのAPIエンドポイントを生成すれば、/resources/login
を唯一のAPIエンドポイントとして、リクエストを集約させることができます。
export const action = async ({ request }: ActionArgs) {
const formData = await request.formData();
const email = formData.get("email");
const password = formData.get("password");
const redirectTo = formData.get("redirectTo");
try {
const user = verfyLogin(email, password);
if (redirectTo){
return redirect(redirectTo);
}
return json(user);
} catch (error) {
return json(
{
status: "error",
error,
},
{ status: 400 }
);
}
}
これだけでもいいのですか、せっかくRemixを使っているので、このAPIエンドポイントをラップするUIコンポーネントを作ることで、UIをインポートするだけでログインができるコンポーネントを作成することができます。
import { Form, useFetcher } from "@remix-run/react";
import { Input, SubmitButton } from "~/component/Form"
export const RoutePath = "/resources/login"
export const action = async ({ request }: ActionArgs) {
// 省略
}
export const InlineLogin = () => {
const fetcher = useFetcher();
return (
<fetcher.Form method="POST" action={RoutePath}>
<Input name="email" required type="email" />
<Input name="password" required type="password" />
<SubmitButton loading={fetcher.state !== "idle"}>ログイン</SubmitButton>
</fetcher.Form>
);
};
ログインを行いたいコンポーネントで、<InlineLogin/>
をインポートすることで、どこからでもログイン処理が行えるようになります。
Resouces Routeを活用することで、UI + サーバーロジック
をコンポーネント化することができ、アプリケーション全体でコードの再利用を進めることができます。
models
models
には、実際にデータベースやサーバーからデータを取得するコードをまとめていきます。
Remixで、テンプレートで用意されているプロジェクトで開発を開始すると、PostgreSQL + Prismaという構成を良く見かけますが、この構成の場合は、Prismaのコードをmodelsに集約させます。
例えば、user
というデータモデルがあった場合、modelsにはuser
のデータを取得するためのPrismaコードを書いていきます。
export async function getUser(id: User["id"]) {
return prisma.user.findUnique({ where: { id } });
}
export async function getUsers() {
return prisma.user.findMany();
}
app/routes
のaction
/loader
でデータの読み込みや書き込みの実態をmodels
に集約させることで、例えばデータベースなどの変更があった場合にも、全てのaction
/loader
を変更しなくとも、コードの変更が容易になります。
components
components
は、再利用性が極めて高いコンポーネントをまとめていきます。
例えば、Form
やSpinner
、ErrorBoundary
などといった特定のドメインに依存することなく利用されるコンポーネントを記述していきます。
イメージとしては、ChakraUIといったコンポーネントライブラリに用意されているようなコンポーネントをapp/components
にまとめていきます。
app/routes
内にドメインにかかるコンポーネント群は集約されていくので、app/component
にはそこから抜き出される汎用コンポーネントをまとめていくような設計にしています。
utils
読んで字の如く、utils
な関数などを配置していくディレクトリです。
便利ツールを置いていくようなディレクトリです。
基本的に、アプリケーション全体で使いた関数系はutils
にぶちこんでいます。
私の場合は、汎用Hooks系や外部ライブラリ系もutils
にぶち込んでいます。
hooks
ディレクトリやlib
ディレクトリの運用も検討していましたが、ここを分けて管理するメリットが見出せなかったので、utils系のコードはすべてapp/utils
にぶち込んでいます。
まとめ
Remixのディレクトリ構成を考える上では、app/routes
を起点にいかにしてコードの凝縮度を高められるように記述できることが重要ではないかと考えています。
フロントエンド開発においては、ユーザーに届ける「画面」からいかにしてアプリケーション全体を構成していくか、という考え方のもと開発を進めていくことが多いかと思います。
Remixを用いることで、「app/routes
を起点に」 = 「画面を起点に」アプリケーション構成を考えていくというフローをフレームワークレベルで道を示してくれるので、開発体験の向上にもつながるのではないでしょうか。
ぜひ、この記事を参考にしていただきながら、 Remix開発の一歩を踏み出して見てください!
Discussion
Outlet
について理解できました。ありがとうございます。