カミナシ エンジニアブログ

株式会社カミナシのエンジニアが色々書くブログです

Reactの状態管理を、ライブラリやコンポーネントではなくモデルから考える

カミナシエンジニアの osuzu です。

「状態管理にどのライブラリを使うか」への違和感

Reactの状態管理の話になると、だいたいライブラリの比較から始まります。少し前なら Redux か Zustand か Jotai か、最近だと TanStack Query と React Hook Form を組み合わせれば残りはわずか、みたいな話が多い印象です。

ただ、読んでいてどこか議論がかみ合わない感じがずっとありました。 理由はたぶんシンプルで、その問いは手前にすべきモデリングを飛ばしているからだと考えてます。

ライブラリ起点・コンポーネント起点はなぜこじれるのか

フロントの状態管理でやりがちな失敗に、両極端な二つがあります。「Reduxを採用したので全部storeに乗せる」や「Reactをシンプルに使うため全部useStateで」です。

Redux や useState が悪いということではありません。問題は、アプリのモデルを持たないまま道具の作法を機械的に当てはめ、状態の置き場所を道具の都合だけで決めてしまうことです。

ライブラリの構造やコンポーネントの構造に、本来モデルが担うべきまとまりを肩代わりさせている。自分も過去そうして沢山苦しみました。

ライブラリ起点:道具の都合が設計を歪める

特化したライブラリ TanStack Query や React Hook Form が普及して良くなった面はたしかにあります。たとえば TanStack Query を使うとサーバー状態の扱いは見違えるほど楽になりますし、多くのプロダクトで実際にうまくいっています。

ただこれがうまくいく理由は、必ずしもライブラリ自体の力だけではないように感じます。サーバーで誰かがすでにモデリングをして、フロントはそれを消費しているだけ、というケースがあります。APIのURLや返す JSON の形がそのままアプリのモデルの形になっているという状況です。

フルサイクルで開発している人にとっては、特に問題にならないと思います。サーバー側でモデルを考えるときも、フロント側で扱うときも、同じ概念を行き来していて、頭の中にモデルがあるからです。

問題は、フロントだけを担当している場合です。この構造は、静かにモデリングの機会を奪っていきます。「サーバーレスポンスのキャッシュ+フォームの値の合算=アプリの状態のすべて」という前提で書ける範囲では、欠けているものが表面化しません。複雑なフォーム、オフライン対応、書き込み時のコンフリクト解決、自動保存 — そういう話が出てきて、はじめて「自分たちはモデルを持っていなかった」と気づきます。

道具の作法に合わせてデータの形を決めると、アプリの構造ではなくライブラリの構造が前に出てきます。atom や queryKey や form field の単位でしか発想できなくなって、まとまりとは別の概念が一つの hooks に追いやられたり、画面ごとに似たような状態が重複したりします。

コンポーネント起点:useState の継ぎ足しで構造が消える

逆の極端が、useState を継ぎ足していくやり方です。

もちろん useState が悪いという話ではありません。むしろ useState はスコープを小さく保てる良い仕組みです。問題は、ドメイン上ひとまとまりであるはずの概念を、複数のコンポーネントの内部状態に分解してしまうことにあります。

分解しすぎると、コードのどこを見てもその概念そのものが姿を見せなくなります。属性は別々のフックの中に散っていて、それらを束ねた型もなければ、操作する関数もありません。アプリ全体としては明らかにその概念を扱っているのに、コードのどこにも名前が付いていない、という状態です。

モデルとして名前を付けるべきものに、名前を付けないまま分解してしまう。これがコンポーネント起点の落とし穴だと感じています。

コンポーネント思考の React は偉大過ぎたのだ…

モデル起点という発想

ビジネス概念を定義する

モデル起点とは、画面より先に「このアプリは何を扱っているのか」を言葉にする、ということです。

ユーザー、注文、編集中のドラフト。これらは、UIにどう描かれているかとは独立に意味を持つ概念です。コンポーネントの寿命やルーティングより長く生きることもあるし、サーバー側のデータモデルとも対応関係を持つケースも多いです。UIはその表現のひとつにすぎません。

モデルから始めると、自然と「このデータは誰のものか」「誰が変更するのか」「変更されたら何が起きるか」を考えることになります。コンポーネントの都合でもライブラリの都合でもなく、ドメインの都合でデータの形を決められます。

モデルか、UI状態か

モデルとUI状態を分ける線を引こうとするとけっこう迷います。自分が使っている目安の一つはシンプルで、ビジネスの語彙で名前が付くかどうかです。

Order、Applicant、Cart。こうしたものはドメインの言葉で説明できます。エンジニアでない人に見せても通じる名前です。

一方で isModalOpen、hoveredIndex、focusedField はビジネスの語彙では説明できません。UIという特定の表現様式があってはじめて意味を持つ概念です。

同じ「選ばれている」でも、カートに入れた商品とリストで選択中の行は別物として扱ったほうがいいと思っています。

操作が「ドメインのコマンド」になるか

もう一つ簡単なテストがあって、その状態を変更する操作をドメインの言葉で名付けられるかを見るやり方です。

placeOrder()、submitApplication()、addToCart()はドメインのコマンドとして読めます。openModal()、setHovered()、toggleDrawer() は画面の振る舞いの域を出ません。

モデルを表現する

ここまでの考え方をコードに落とすとどうなるか、ひとつ具体的なドメインを置いて見ていきます。

題材は採用領域です。よくあるDDDの教材的な例ですが、採用選考(Screening)を集約のルートにして、応募者(Applicant)の情報と、面接(Interview)の連なりを内包する、という整理を考えてみます。画面ではこの選考を一件編集する想定です。

Screening
  ├ Applicant (name, email)
  ├ status (screening / hired / rejected)
  └ Interview[] (面接の連なり)

以下は React と Jotai を使った例です。Jotai 固有の API も出てきますが、やっていることはライブラリに依存しません。

モデルを型やスキーマで宣言する

最初にやるのは、モデルの型を宣言することです。状態管理の話というより、ドメインモデリングの話です。ツールは限りませんが一般例としてzodを使っています。

const screeningSchema = z.object({
  screeningId: z.string(),
  applicant: z.object({
    name: z.string(),
    email: z.string(),
  }),
  status: z.enum(["screening", "hired", "rejected"]),
  interviews: z.array(interviewSchema),
});

export type Screening = z.infer<typeof screeningSchema>;

重要なのは「Screening というモデルが存在する」と先に宣言してしまうことのほうです。 このモデルをひとつの atom にします。

const screeningAtom = atom<Screening>(defaultScreening);

これで Screening というモデルのインスタンスがアプリ内に存在することになります。ストアの中の slice ではなくて、モデルそのものへの参照、という捉え方です。

モデルとコンポーネントを接続する

UI が必要とするのはモデル全体ではなく、その一部です。応募者の名前だけを表示するコンポーネント、面接の一覧、個別の面接、というふうに必要な粒度が違います。

コンポーネント起点で書くと分解が起こりがちですが、モデル起点で書くとモデル側を切らずに済みます。focusAtom でモデルの一部にフォーカスし、splitAtom で配列要素を独立した atom として扱う、というやり方です。

const interviewsAtom = focusAtom(screeningAtom, (o) => o.prop("interviews"));
const applicantAtom = focusAtom(screeningAtom, (o) => o.prop("applicant"));
const interviewsSplitAtom = splitAtom(interviewsAtom);

この使い方は React 外のコミュニティで人気の Fine-grained Reactivity(Signals) に近く、コンポーネントの再レンダリングを最小化しつつ、モデルの一体性は壊さない、ということが両立します。

focusAtomsplitAtom も Jotai 固有の API ですが、やっているのは「モデルを切らずに一部だけを見る」ことだけです。考え方はライブラリに依らないので、Signals など別の道具でも実現できます。

ドメインコマンドを action atom として書く

モデルの遷移はモデル側のロジックで完結させます。Jotai の write-only atom、いわゆる action atom はそのための場所として使えます。

export const addInterviewAtom = atom(null, (get, set) => {
  const current = get(interviewsAtom);
  if (current.length >= MAX_INTERVIEWS_PER_SCREENING) {
    throw new Error("これ以上面接を追加できません。");
  }
  set(interviewsAtom, [...current, createEmptyInterview()]);
});

ポイントは「面接を追加する」というドメインのコマンドが、コンポーネントの外に独立して存在していることです。件数の上限のようなドメインルールもここに住んでいて、UI 側には漏れません。並び替えや削除も同じ形で書けます。

コンポーネント側は useSetAtom(addInterviewAtom) で「面接追加」というコマンドを取り出して、押す。それだけになります。

すべてをモデルにしない

ここまで Jotai を使った例で書きましたが、「すべての状態にモデルを置こう」と言いたいわけではありません。モデルが要らない場面でモデルを持ち出すと、今度は逆方向の道具の使いすぎになります。

補足すると、本記事の主軸は「モデルか、UI状態か」であって、「atom か、useState か」ではありません。Jotai の atom は <Provider> でストアをスコープできますし、コンポーネントの中で作って使えます。atomを使う=モデル化してグローバルに置く、ではないですし、useState を Context 通してグローバルなモデルのように扱うこともできます。

その上で私は、Jotai のようなライブラリを使うことでドメインロジックの表現や、Reactでモデルとコンポーネントを接続する場面で、パフォーマンス最適化がしやすいと考えています。

結局ある程度は道具側の知識も要るということですね笑

自分の中で、モデル相当のものは Jotai、それ以外のローカルなUI状態は useState、というふうに使い分けています。ここの線引きや選定はチームやプロジェクトの好みで変わって良いところだと思います。

UI状態はコンポーネントや hooks に閉じ込める

モーダルの開閉、ホバー、フォーカス、入力中の未確定値、こういったものはコンポーネントや hooks の中で完結させたほうがシンプルです。前に書いた「ビジネスの語彙で名前が付かない」状態がだいたいここに該当します。

Prop drilling を避けたいというだけの理由で、UI状態をグローバル化したり、モデルとして扱ったりするのは避けます。

サーバーで完結するリソースは TanStack Query で

サーバーが真実で、フロントは表示と編集だけ、編集も即時に PUT / PATCH で反映してよい、というケースもよくあります。ユーザー設定、ステータス一覧、要件がシンプルなフォームがこれにあたります。

こういう領域では、わざわざクライアントにモデルを持つ必要は薄いと思っています。サーバーから取ってきたデータをキャッシュとして扱い、変更があったら invalidate して再フェッチする、という TanStack Query 的なやり方がそのまま素直に当てはまります。

クライアント側にモデルを置く価値が生まれるのは、編集途中の状態をタブ閉じても保ちたい、複数の編集を集約として束ねたい、UXのためフロントで複雑なバリデーションや楽観的更新したい、といった性質がある場合です。

まとめ

設計順序:モデル → 置き場所 → ライブラリ

状態管理の設計には順序があると思っています。まずモデル、次に置き場所、最後にライブラリ、という順番です。

この順序を逆にすると道具に振り回されます。ライブラリから始めると道具の作法でデータが歪み、コンポーネントから始めると構造が消える。どちらも経験があります。

※ ただし必ずしも設計から始める方が良いとは考えてません。フロントエンドだと実装と設計を行き来することでモデルがよくなるケースも多いと思います。

ライブラリの行き着く先は同じ問い

最終的に、Signals でも Jotai でも useState でも、良い状態管理に共通しているのは、その下にモデルがちゃんと定義されていることだと感じます。ライブラリの差より、モデルの有無のほうがずっと効きます。

「状態管理どうする?」と聞かれたときに、最初に返したい問いは「何を、どんなまとまりで持つべきか?」だと思っています。

AIとの距離感

最後にAI 全盛の今のスタンスです。 私は Jotai を使うときも使わないときもあります。それはツールで設計を考えたくないからです。

そのため設計フェーズでの Agent skill のような自動化にも重きを置いておらず、ドメインモデリングも特定の skill に頼っているわけではありません。

AI は作業は短縮してくれるけれど、理解は短縮してくれません。コードを書く時間やドキュメントを読む時間は確かに減ります。けれど、自分たちのアプリが何を扱っていて、どの概念が中心で、どこに境界があるのか、ということを理解する時間は、AI に渡せるものではありません。

状態管理の中で本質的に難しい部分は、まさにそこです。ライブラリ選びや実装の手数は道具に任せて、その分の時間をドメインを理解することに振りたい、というのが今の感覚です。


宣伝

カミナシではフロントエンドに強みを持ちつつ、フルサイクルで開発しモデリングを大切にするエンジニアを募集しています。この記事に共感頂けるような方はきっと楽しめると思います。