React Router v7で実現するuseStateゼロ開発
年末にReact Router v7がリリースされましたね!
これによりWebフレームワークであるRemixとルーティングライブラリであるReact Routerが統合され、1つのプロジェクトとなりました。
React Router v7で実現する完全純粋コンポーネント開発
統合自体が一つの大きなニュースなのでそこに注目が集まりがちですが、当然書き方もいくつか変化しています。
私としてはこの書き方の変化からほとんどのコンポーネントを純関数で表現できるようになったことに大きく注目しています。
純関数とは
純関数とは、ひとことでいうと 隠れた入出力がない関数のこと です。
隠れてない入力は引数、隠れてない出力は戻り値で、それ以外の入出力がある関数と言えます。
いくつか例をあげると、以下のようなものが挙げられます。
// 純関数
function add(a: number, b: number): number {
return a + b;
}
// 純関数でない(隠れた入力)
let i = 0;
function getUser(id: number): User {
i++;
return i;
}
// 純関数でない(隠れた入力:データベース)
function getUser(id: number): User {
return userRepository.getUser(id)
}
// 純関数でない(隠れた出力=副作用)
function reserveMeeting() {
...
slackService.notify("Meeting reserved")
}
純関数はプログラミング全般において推奨されることの多い考え方です。
たとえば書籍『単体テストの考え方/使い方』ではテスタビリティを高めるためには純関数を増やすのが大事としてます。
React公式でも1ページかけてコンポーネントを純粋にする方法を解説していますので、詳しく知りたい方はこちらもご参照ください。
フロントエンド開発の複雑性と副作用
フロントエンド開発の難しさは、ほぼ状態管理の複雑さに起因します。
フロントエンドは非同期での状態変更、ユーザーの操作とあらゆる状態が複雑に変化していきます。
さらにパフォーマンス向上のため状態はキャッシュされ、レンダリングのタイミングもコントロールされます。
つまりさまざまな時間軸において複雑に状態が変化し、また常に最新の状態がレンダリングされているわけではないというわけです。
Reactやエコシステムの進化によってこうした複雑性は極力宣言的に、イージーに書けるようにはなってきました。
とはいえdependenciesの変更のタイミングや、Next.jsやReactQueryのキャッシング、テストコードを書く難しさなど本質的な複雑性は残ったままです。どこかで問題が発生したときに細部への深い理解が必要となり、悩まされることに変わりはありません。
そもそも複雑である必要はあるのか
しかし世の中のサービスの多くはバックエンドから渡ってきた値を表示し、フォームでバックエンドと疎通し、かろうじてモーダルが出てくる程度のものがほとんどです。
こうしたサービスにおいて、そもそもこんなに複雑に副作用を取り扱う必要はあるのでしょうか?
古くはテンプレートエンジンにコントローラから渡ってきた値を埋め込み、バックエンドでそのままHTMLを返却していました。
こうしたシンプルなサービスであれば、もはやuseState
すら全く使わずに、完全に純関数のみで構成できるのがReact Router v7です!
完全純粋コンポーネント開発のやりかた
では、React Routerを使った完全純粋コンポーネント開発のやりかたをみていきましょう。
loader
関数
ルートコンポーネントとReact Routerでは、それぞれのURLパターンに対しルートモジュールを設定していきます。
ルートモジュールでdefault exportされたコンポーネントがルートコンポーネントとなり、そのパスにマッチした際にレンダーされます。
export default [
index("./home.tsx"),
route("about", "./about.tsx"),
layout("./auth/layout.tsx", [
route("login", "./auth/login.tsx"),
route("register", "./auth/register.tsx"),
]),
...prefix("concerts", [
index("./concerts/home.tsx"),
route(":city", "./concerts/city.tsx"),
route("trending", "./concerts/trending.tsx"),
]),
] satisfies RouteConfig;
ルートコンポーネントでは loader関数から戻り値を受け取り、そのままコンポーネントのProps経由で受け取ることができます。
つまりバックエンドから値を受け取り、その結果をもとに表示する分には完全に純関数として表現できるわけです。
export async function loader({ params }: Route.LoaderArgs) {
return await fakeDb.getProduct(params.pid);
}
export default function Product({
loaderData: { name, description },
}: Route.ComponentProps) {
return (
<div>
<h1>{name}</h1>
<p>{description}</p>
</div>
);
}
action
関数
ではユーザの追加、削除、更新などのデータの変更処理はどうでしょうか。
React Routerでは、action
関数に処理を記述し、フォームやfetcher, submit関数によりそれを呼び出すことができます。
actionの結果についてもコンポーネントのProps経由で受け取ることができるため、シンプルなフォームのようなものであれば純関数を保つことができます。
export function action({ request }: Route.ActionArgs) {
const formData = await request.formData();
const title = await formData.get("title");
const project = await someApi.updateProject({ title });
return project;
}
export default function DashboardUsersList({
actionData,
}: Route.ComponentProps) {
return (
<div>
<Form method="post">
<input type="text" name="title" />
<button type="submit">送信</button>
</Form>
{actionData ? (
<p>{actionData.title} が更新されました</p>
) : null}
</div>
)
}
パスパラメータやクエリパラメータによる状態の表現
バックエンドとの疎通はProps経由で受け取ることができました。
ですが現実問題として、useState
で表現したい状態はまだまだあるかもしれません。
その場合もパスパラメータやクエリパラメータで表現できないか考えてみましょう。
パスパラメータやクエリパラメータで表現することでコンポーネントの純粋性を保てるだけでなく、状態の変更をリンクで表現できるようになります。
たとえばページネーションをクエリパラメータで表現すると以下のように記述できます。
export function loader({ request }: Route.LoaderArgs) {
const url = new URL(request.url);
const searchParams = url.searchParams;
const pageParam = searchParams.get("page");
const page =
pageParam && !isNaN(Number(pageParam)) ? Math.max(1, Number(pageParam)) : 1;
return {
users: await getUsers(page),
page,
};
}
export default function DashboardUsersList({
loaderData: { users, page },
}: Route.ComponentProps) {
return (
<>
<Table>
<TableBody>
{users.map((user) => ( ... )}
<PaginationNext href={`/users?page=${page + 1}`} />
こうした単純な処理もフロントエンドで全て表現しようとすると、データをとってきてキャッシュして、場合によってはuseMemoでメモ化してなど、複雑化するものです。
クエリパラメータで表現することで、Propsから受け取ったusers
を描画し、リンクを作るだけのシンプルなコンポーネントにすることができるわけですね!
Nested Routesによるページ分割
さて、こうしてルートコンポーネントが純関数となるとそのページ内のあらゆる情報が最初に取得され、それが子コンポーネントに伝播していくことになります(Props Drilling)。
こうして副作用を親に寄せコンポーネントの大部分を極力純関数で表現する考え方はContainer/Presentationalパターンとして昔からありましたが、過度にContainerが肥大化してしまうと逆に可読性が落ち複雑化していくこともありました。
React RouterではNested Routesを用いることでページの一部を別のルートとして独立させ、この複雑性を軽減することができます。
たとえば、この記事ではNested Routesを用いてタブを表現する例を解説しています。
- user.tsx(共通でレンダリングされるルートモジュール)
- user/profile.tsx(プロフィールタブのルートモジュール)
- user/account.tsx(アカウントタブのルートモジュール)
Nested Routesでは、<Outlet />
コンポーネントにより小要素を描画することができます。
たとえば、以下のように記述した場合は<Outlet />
内にuser/profile.tsx
やuser/account.tsx
のルートコンポーネントが描画されることになります。
user.tsx
return (
<>
{/* 共通でレンダリングされる要素 */}
<h1>User</h1>
{/* タブ */}
<nav>
<Link to="/user/profile">Profile</Link>
<Link to="/user/account">Account</Link>
</nav>
{/* タブで切り替わる要素 */}
<main>
<Outlet />
</main>
{/* 共通でレンダリングされる要素 */}
<Footer />
</>
);
Nested Routesにおいて、ルートコンポーネントごとにloader
が実行されます。
したがって次のようにタブ固有の処理はそのタブのルートコンポーネントにのみ記載することで、ページ全体の複雑性を軽減していくことができます。
また、その要素固有の更新処理もそのパスのaction
に記載することで、単一のaction
の責務が肥大化してしまうことも防ぐことができるでしょう。
user/profile.tsx
export function loader({ request }: Route.LoaderArgs) {
const profile = const await getUserProfile()
...
}
export function action({ request }: Route.ActionArgs) {
...
await updateProfile(user)
...
}
export default function UserProfile({
loaderData
}: Route.ComponentProps) {
return (
<SubHeadings>プロフィール</SubHeadings>...
Route-based Modal
ではモーダルについてはどうでしょうか。
Nested Routesを使えば、モーダルの開閉状態もuseState
を使わずに表現することができます。
先ほど解説したようにNested Routesでは<Outlet />
内に子ルートが描画されます。
つまり子ルートコンポーネントでモーダルを返せば、親ページの要素を描画しつつ、<Outlet />
にモーダルを描画することができるわけです。
users.tsx
export default function Users({
loaderData: { users, page },
}: Route.ComponentProps) {
return (
<>
<Button asChild><Link to="/users/new">Create User</Link></Button>
...
<Outlet />
</>
);
}
users/new.tsx
export async function action({ request }: Route.ActionArgs) {
...
}
export default function UsersNew() {
return (
<Dialog>
<Form method="post">
<input type="text" name="firstName" placeholder="First Name" />
<input type="text" name="lastName" placeholder="Last Name" />
<Button type="submit">Create</Button>
{/* モーダルを閉じる処理もリンクにできる */}
<Button asChild><Link to="/users">Close</Link></Button>
</Form>
</Dialog>
)
}
このようにモーダルを表現することで、モーダルごとのaction
もそれぞれのパスに記載することができます。
新規作成モーダルであればusers/new.tsx
に、更新モーダルであればusers/:id/edit.tsx
に記載してその中のaction
関数で処理を記載することで、モーダルとそれに対応した処理をわかりやすく管理することができるわけです 。
ルートコンポーネントのテスト
本来ルートコンポーネントのテストはMSWでAPI疎通モックしたりフレームワークのモックをしたり、準備が必要になるケースがほとんどです。そのためルートコンポーネントのテストは書かないプロダクトも多いのではないでしょうか。
本記事のようにルートコンポーネントも引数を受け取って単一の画面を返すだけの純関数にできれば、テストも簡単に記述することができます。
バックエンドからの戻り値や、更新処理の結果、ページネーションなどあらゆる状態がPropsで渡されるのであれば、以下のようにルートごとのStoryを用意することであらゆる状態を再現することができます。
export const Default = Template.bind({});
Default.args = {
loaderData: {
users: [userFactory.create(), userFactory.create()],
page: 1,
},
};
export const Empty = Template.bind({});
Empty.args = {
loaderData: {
users: [],
page: 1,
},
};
であればPlaywrightやChromaticを用いて各Storyに対してVRT(Visual Regression Testing)を回すことで、あらゆる状態に対しての動作を担保することができます。
加えてユーザの操作もリンクで表現できれば、リンク先URLの正しささえ保証できればそのページの機能の大部分は担保できると言えます。
このように純関数で表現することで、テスタビリティも大幅に向上させることができるわけです。
まとめ
React Router v7による完全純粋コンポーネント開発について、その利点と実装方法を見てきました。
従来のReactアプリケーション開発では、状態管理の複雑さに悩まされることが多くありました。useStateやuseEffectの使用、非同期処理の管理、そしてキャッシュの制御など、様々な要素が複雑に絡み合っていました。しかし純粋コンポーネントを意識することでこの複雑さを大幅に軽減することができます。
あえて本記事では極論っぽく書きましたが、もちろんすべてのケースでこのアプローチが最適とは限りません。複雑なインタラクションやリアルタイムな状態更新が必要なケースでは、従来の手法との組み合わせが必要になります。何でもかんでも純粋コンポーネントにするよりは素直にStateを使った方がシンプルに書けるケースも多々あるでしょう。過剰にNested Routesを用いることで無駄にloaderが実行されてしまい、パフォーマンス面でネックになってくることはあるかもしれません。
現実問題として純粋でなくなる箇所はどうしても出てきます。それでも、可能な限り純粋コンポーネントを目指すことでアプリケーション全体の品質向上につながることは間違いありません。
そして何より複雑なページを純関数として表現しようとするのは楽しく、できたときは気持ちよいものです。
この記事で一人でも多くの人がRemixもといReact Routerに興味を持ってくれれば幸いです!
Discussion