🧐

Reactの書き方について思ったこと

に公開

はじめに

先日公開した「君たちはReactをどうやってRuby on Railsに載せるべきか?」の中で、ReactをRailsに載せるモダンな最適解はReact Router SPA modeを使うことだろうという話をしました。

これをより簡単にするためにreact_router_rails_spa gemも用意し、さらにデモも用意しました(rrrails.castle104.com)。

このデモを作りながら、「そもそもReactってどうやって書くべきなんだろう?」 について色々考えたので、ここで簡単に紹介したいと思います。

いくつかの現場のReact/Vueの書き方を見た上で、自分の考えを合わせたものになります。ただしこのやり方でプロダクトを作ったわけではありませんので、ご了承ください。

各ページに特化したJSON APIか、それともテーブルを映したAPIか?

現場のコードはテーブル指向とページ指向が中途半端に混ざっている

JSON APIの設計方法としては、大きく2つの考え方があります。

  • DBのテーブル構造をそのまま反映したもの(ここではテーブル指向APIと呼びます)
  • 表示ページの内容に合わせたもの(ここではページ指向APIと呼びます)

現場のコードを見ると、シンプルなテーブル指向APIから出発するものが多いです。そしてUIの要件が増えるに従ってリクエストパラメータや呼び出し先URL、さらに権限に応じてフィールドの出し分けをするようになっていきます。またN+1問題の対策として条件によって子要素を先読みしてAPIに含めたりします。

こうするとサーバサイドのロジックは複雑になっていきます。最初からページ指向APIにした方が楽だったのではないかと考え始めてしまいます。またBob Martin氏はClean Codeで「フラグ引数(Boolean)で処理が分岐するような関数は避けること」としていますが、Serializerにパラメータが追加され、だんだんとこのアンチパターンにハマっていく気がしてきます。

サーバやDBの負荷も心配になります。テーブル指向APIの場合は、ひとつのページを表示する際に複数のAPIリクエストを飛ばします。各APIエンドポイントでは同じルーティングと認証・認可の処理が行われ、そのために同じSQLを繰り返しDBに飛ばすことになりがちです。

またフロントエンドのTypeScript的には、nullとかundefinedを許容したTypeが増えていきます。なぜならば1つのserializerに対して1つのTypeを定義することが多いので、条件によって出し分けされるフィールドはoptionalにしなければなりません。せっかくのTypeScriptも厳密さと分かりやすさが失われていくようで悲しくなります。

ページ指向APIに完全に振り切ることのメリット

現場のコードはこの辺りの考え方が中途半端になっているものが多いと感じています。そこで今回のデモアプリでは、思いきってページ指向APIに完全に振り切ってみました。徹底するということは次のことです。

  • 1つのページに必要な情報は全て1つのレスポンスで返す。これは権限情報なども含む
  • ページが必要としていない情報はレスポンスに一切のせない
  • 再利用は期待せず、基本的にはページとレスポンスを1対1対応させる

徹底することによってビジネスロジックだけでなく、データの形を整えて表示しやすくするロジックも完全にバックエンドに持たせることができました。Railsのjbuilderを使っていることもあり、こうするとJSON APIの作成はERBの作成とかなり近い感じになります。例えば下記の箇所では、上記に実際に表示される<table>をイメージしながらJSONを書いています。

app/views/posts/index.json.jbuilder

json.posts do
  json.array! @posts do |post| # , partial: "posts/post", as: :post
    json.extract! post, :id, :content
    # authorはUserテーブルから取ってくるが、使用するのは"email"だけ。
    json.author do
      json.extract! post.author, :email
    end
    # 各Postの権限ロジックも、表示ロジックもサーバから送る
    json.highlighted Permissions::Post.can_edit?(current_user, post)
    json.can_edit_post Permissions::Post.can_edit?(current_user, post)
    json.created_at post.created_at.iso8601
  end
end

そしてフロントエンドではPostとかAuthorのTypeは作らず、ページ指向APIの形に厳密に合わせたZod指定だけで型情報を作っています。再利用を無視してTypeが作れますので、複雑に考えることがなくなります。

frontend/app/routes/posts/home.tsx

// PostもAuthor(User)もTypeは作らない。
// ページ指向API固有のTypeだけをZodに作らせる。
export const apiSchema = z.strictObject({
  posts: z.array(z.strictObject({
    id: z.number(),
    content: z.string(),
    author: z.strictObject({
      email: z.string(),
    }),
    highlighted: z.boolean(),
    canEditPost: z.boolean(),
    createdAt: z.iso.datetime({local: true}).transform((date) => new Date(date)),
  })),
  pagination: z.strictObject({
    prevPage: z.number().nullable(),
    nextPage: z.number().nullable()
  }),
  permissions: z.strictObject({canCreatePost: z.boolean()})
});

まだ現場で試していないのでまだ何とも言えないのですが、少なくとも私の経験では一番スッキリとしたReactが書けたように感じました。

  • ロジックをサーバ側に寄せやすい
  • エンドポイントごとにjbuilderファイルを作るので、Serializerは「フラグ引数(Boolean)で処理が分岐するような関数」にならない
  • フロント側でnull|undefinedが多いようなTypeを作らなくて良い
  • 冗長さが増す可能性はあるが、一方で無理な再利用(DRY)をしないので、むしろスケール拡大に対応しやすいはず

Global stateはReact Routerのlayoutに持たせれば良くない?

昔はGlobal stateの管理にReduxなどの大袈裟なライブラリがよく使われていましたが、サーバ通信頻繁に行う一般的なウェブアプリについては、Global stateはほぼ不要とも言われるようになりました。使う場合でも用途は随分と限定され、ログインしているユーザの情報や画面テーマなどぐらいなことが多くなっています。

限られたGlobal stateを管理する方法としてはReactのuseContext()、Redux Toolkit, 3rdパーティーのZustandやJotaiなどがあります。どれも昔のReduxよりシンプルになっていて、用途が限定されているなら使いやすくなっています。

しかしこれらのツールはあくまでもGlobal stateの管理を目的としています。中に入れるべきデータをどのタイミングでリクエストするべきか、さらにはそれをどのタイミングで破棄すべきかは対象外です。

一方でReact RouterのlayoutはGlobal state的な側面を持ちつつ、ライフサイクル管理がしやすくなっています。

  • 各画面はlayoutを持つ。またlayoutは複数の画面を持つ。layout has_many 画面の関係
  • URLを開くと、それに対応するlayoutが作成され、clientLoader()関数が自動的に呼ばれる
  • 同じlayoutを使う画面間の遷移であれば、clientLoader()関数が再度読み込まれることはなく、返り値が事実上キャッシュされる
  • layoutのclientLoader()の返り値は下流のcomponentからuseRouteLoaderData()で呼び出せるので、layoutでスコープされたglobal state的に使える
  • React RouterはclientAction()の中でredirect()をすると、自動的にclientLoader()のキャッシュを無効にする。よってPost/Redirect/Getの黄金パターンに従えば、Global stateを明示的にクリアしなくても、ほとんどの場合はReact Routerが勝手にクリアしてくれる
  • layoutごとのstateなので、例えばadminが画面を別layoutで管理すれば、admin画面からしかアクセスできない、専用のstateが作れる

frontend/app/routes/posts/home.tsx

export default function PostsHome({loaderData}: Route.ComponentProps) {
  ...
  const {context} = useApplicationContext()
  ...

最後に

ReactやVueフロントエンドのコード、およびそれをサポートするバックエンドAPIのコードを見ていると、不必要に複雑化していると感じることが多々あります。それを見ながら、もっと良いやり方があるのではないかと考えるのが私の一つの趣味です。

中でもページ指向API設計をするべきか、それともテーブル指向API設計をするべきかという悩み、またglobal stateを楽に管理するにはどうするべきかという悩みは、ずっと頭から離れませんでした。たまたま今回、自分の中でスッキリと解決できた気がしたので記事にしてみました。

Discussion