🦔

Remixで爆速開発できるディレクトリ構成のススメ

2023/06/28に公開1

Hello, Remix.

こんにちは!Acompanyのマッケイです!

この記事は Acompany5周年アドベントカレンダー 28日目 の記事です。

https://recruit.acompany.tech/b6d945cfebca4be0876128af68dd5d8b

今回は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は以下のようになります。

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というディレクトリでユーザーごとに商品の追加が可能です。

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の公式ページをご覧ください。

https://remix.run/#:~:text=Remix has a,↑↑↓↓←→←→BA

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内でネスト管理することが可能です。

salesloaderを通して取得したデータは、invoices,$invoiceIdからもアクセスできます。

これは、フレームワークレベルでグローバルなステート管理が可能になることを示しており、ステート管理ライブラリを用いることなく、ステートを取り扱うことができるようになります。

データレイアウトのネスト

Routeでのデータの受け取りは、useRouteLoaderData関数を使うことで、任意の親RouteのStateにアクセスをすることができます。

アイディア③: コロケーション

Remixの公式やサンプルプロジェクトなんかを見ると、主要なコードはroutes下に配置されるような構成をよく見ます。

実際に、Remixのコントリビュータであるライアンさんもコロケーションについて言及しており、Remixには「コードを関連する場所のできるだけ近くに配置する」という考え方が含まれています。

個人的にもこの考え方は賛成であり、Reactの構成でよく見るcomponenthookpagesapiという機能ベースでのディレクトリ構成は無駄にコードを分散させてしまい、コードベース全体の見通しが悪くなると思っています。

アプリケーション内で一回しか呼び出されない関数を、無駄に汎化させて分割することはあまり得策ではありません。

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というようにパスを追加することが可能です。

teststorybookも集約できます。

.
├── 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エンドポイントとして、リクエストを集約させることができます。

app/routes/resources/login/index.tsx
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をインポートするだけでログインができるコンポーネントを作成することができます。

app/routes/resources/login/index.tsx
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コードを書いていきます。

app/models/user.server.ts

export async function getUser(id: User["id"]) {
  return prisma.user.findUnique({ where: { id } });
}

export async function getUsers() {
  return prisma.user.findMany();
}

app/routesaction/loaderでデータの読み込みや書き込みの実態をmodelsに集約させることで、例えばデータベースなどの変更があった場合にも、全てのaction/loaderを変更しなくとも、コードの変更が容易になります。

components

componentsは、再利用性が極めて高いコンポーネントをまとめていきます。

例えば、FormSpinnerErrorBoundaryなどといった特定のドメインに依存することなく利用されるコンポーネントを記述していきます。

イメージとしては、ChakraUIといったコンポーネントライブラリに用意されているようなコンポーネントをapp/componentsにまとめていきます。

app/routes内にドメインにかかるコンポーネント群は集約されていくので、app/componentにはそこから抜き出される汎用コンポーネントをまとめていくような設計にしています。

utils

読んで字の如く、utilsな関数などを配置していくディレクトリです。

便利ツールを置いていくようなディレクトリです。

基本的に、アプリケーション全体で使いた関数系はutilsにぶちこんでいます。

私の場合は、汎用Hooks系や外部ライブラリ系もutilsにぶち込んでいます。

hooksディレクトリやlibディレクトリの運用も検討していましたが、ここを分けて管理するメリットが見出せなかったので、utils系のコードはすべてapp/utilsにぶち込んでいます。

まとめ

Remixのディレクトリ構成を考える上では、app/routesを起点にいかにしてコードの凝縮度を高められるように記述できることが重要ではないかと考えています。

フロントエンド開発においては、ユーザーに届ける「画面」からいかにしてアプリケーション全体を構成していくか、という考え方のもと開発を進めていくことが多いかと思います。

Remixを用いることで、「app/routesを起点に」 = 「画面を起点に」アプリケーション構成を考えていくというフローをフレームワークレベルで道を示してくれるので、開発体験の向上にもつながるのではないでしょうか。

ぜひ、この記事を参考にしていただきながら、 Remix開発の一歩を踏み出して見てください!

Happy Hacking😎

Acompany

Discussion