フロントエンドの「モデル」って結局何なのか、定義し直してみた

開発

フロントエンドの「モデル」って、結局何なんだろう。

正直、多くの画面は API から来たデータをそのまま props に流すだけで回る。“モデル” なんて大層なものは作ってないし、それで困らない。

でも時々、手が止まる瞬間がある。「このデータ、専用の型に詰め替えるべき?」「ここはモデルを作った方がいい?」「ViewModel と腐敗防止層(ACL)、どっちで受ける?」。調べるとDDDだクリーンアーキだと話が大きくなって、結局よく分からないまま元の API 直流しに戻る。

この「FEにモデルって要るのか、要るとして何なのか」を、ずっとフワッとさせたまま書いてきた。今回それを定義し直したら、迷いがだいたい同じ場所から出てた、と分かった。コードの正解集じゃなくて、考え方の整理。

モデル要る/要らないの二択は、たぶん筋が悪い

「FEにモデルは要るか」を要る/要らないで考えると、答えが出ない。要る派は「ドメインを守れ」と言うし、要らない派は「フロントはJSON色付け係だ」と言う。どっちも一理あって、噛み合わない。

噛み合わないのは、その手前がボヤけてるから。“そもそもフロントって何をするものか” がハッキリしてないと、“その中でモデルがどこに座るか” も決まらない。だから順番を逆にする。先にフロントを定義して、モデルの居場所をそこから見る。

フロントの正体は「状態をUIに変え、操作を次の状態へ還す循環」

一番素朴に言うと、フロントは状態(state)を UI に映すもの。でもそれだけだと表示装置で終わる。実際はボタンを押せば状態が変わって、新しい状態がまた画面になる。一方向じゃなく循環してる。

フロントエンドとは、状態を UI に変換し、操作を次の状態へ還す循環。

データの流れで描くとこの1本。細かい言葉は後で説明するから、まずは全体像だけ。

APIレスポンス   信用できない・server由来

parse         実行時の検証 + 自分の型への翻訳   ← 入口のゲート(ACL)

Model         検証済みの状態・判定/ルールを内包

selector      整形・絞り込み・導出             ← 出口(ViewModel)

View          UI(描画するだけ)

操作          action / mutation

次の状態   ──▶ 先頭へ循環(client state を更新 / server は書込→再取得)

“モデル” はこの流れの途中にある一地点でしかない。要る/要らないの議論は、この地点に何を置くか、置かないか、の話だったわけ。じゃあ各駅を順に降りていく。

入口は、信用できないAPIを一度だけ通すゲート

状態の多くは API から来る。で、API レスポンスは信用できない。TypeScript の型注釈は「コンパイル時の約束」でしかなくて、API が約束を破った実データを流してきても気づけない。snake_case だったり、null だらけだったり、enum が生の文字列だったり。これをそのまま画面まで持っていくと、API 都合の形がアプリの隅々まで漏れて、API が変わるたびにあちこちが壊れる。

だから境界で一度、実行時に検証して自分の都合の型に翻訳するゲートを通す。zodparse する、あの1点。Alexis King の「Parse, don’t validate」そのもので、ここで信頼できる型に落とせば、下流は二度と検証しなくていい。

ここで初めて “モデル” が生まれる。そしてこれが、さっきの「ViewModel か ACL か」の片割れ。ACL(腐敗防止層)って、この入口のゲートのこと。大層な名前だけど、やってるのは parse 1個。

入口はAPIだけじゃない。ユーザーのフォーム入力も “信用できない外部データ” で、これも境界で parse(zodのスキーマ)して信頼できる状態に変える。向き違いの、もう一枚のゲート。だから「二度と検証しなくていい」は “一度 parse した同じデータ” の話であって、入力という別の外部にはちゃんと別のゲートが要る。外から入ってくるものは全部、信用しないで一度通す——それが原則。

ただ「全部」には但し書きを付けたい。何を信用できないと見なすかは、相手が外部か自社かより「型契約が保証されてるか」で決まる。サードパーティのAPIは契約が緩いから、参照系(GET)でも parse して翻訳する価値がある。逆に自社APIで OpenAPI や tRPC の codegen を噛ませて型をエンドツーエンド共有してるなら、参照系の実行時 parse はほぼ重複で、zod スキーマを二重メンテするコストの方が勝つことが多い。もちろんデプロイのズレ(フロントは新版・バックエンドは旧版)や手書き型の drift で実データが食い違う余地は残るけど、そこは頻度とコストで割る話。原則の芯は「外から来たら通す」じゃなく『型が保証されてないなら通す』の方だ。だから自社の参照系は、ゲートを薄く——あるいは無し——にする判断が普通にあり得る。

状態にはサーバの正本とクライアントの正本がある

「状態」と一括りにしてきたけど、ここを割るとモデルの置き場がさらにハッキリする。状態には正本(source of truth)がサーバにあるものと、クライアントにあるものがある。

  • サーバが正本(server state)。注文一覧、プロフィール、商品。本体はサーバのDBにあって、手元のは借りたキャッシュにすぎない。他人も書き換えるし、いつか古くなる(stale化する)。だから仕事は「保持」じゃなく「キャッシュ管理」。React Query や Server Component が解いてるのはこれ。
  • クライアントが正本(client state)。モーダルの開閉、選択中のタブ、入力途中、フィルタ。そのブラウザの中だけにあって、問い合わせる先がない。リロードで消えても基本困らない。Zustand / Jotai / Redux に置くのはこっち。

昔のReduxが苦しかったのは、性質の違うこの2つを1つの箱に詰めて、API由来のデータに「キャッシュが古くないか」「再取得は」を全部手で書いてたから。「サーバに同じものがある?」と一回問うだけで置き場はほぼ決まる。Yesならキャッシュ扱い、Noならstoreに持つ。

よくやる事故が、APIで取ったデータをuseStateにコピーして持つやつ。あれはサーバ正本をクライアントに複製して、二重管理とstale化を自分で作り込んでる状態なんだよね。僕も昔さんざんやった。「消えかけたモデルの幽霊」みたいなもので、見つけたら大体消せる。

筆者

出口は、画面用に整える selector

検証済みのモデルを、画面はそのまま使えないことが多い。日付を"6月20日"にしたり、配列から未読だけ取り出したり。この整形が流れの出口。

コツは、整形を「持つ」んじゃなく「計算する」こと。API の status(5種類)を画面用の3状態に丸めるとき、丸めた値をモデルに焼き付けず、statusを持ったまま純粋関数で導出する。元を捨てなきゃ、後から別の切り口が要っても関数を足すだけで済む。丸めた値は計算で作る派生物、元の status が情報源。React で言えば、派生状態をuseStateに持たずレンダリング中に計算する、あの規律のモデル層版だ。

これが「ViewModel か ACL か」のもう片割れ。ViewModel=この出口の整形で、selector がやってる。

で、その整形の中身には性質の違う2つが混ざる。

  • 整形(見せ方)。フォーマット、絞り込み、結合。デザインが決める。間違えても見た目が崩れるだけ。画面ごとに違ってよくて、selectorの責務。
  • 判定(ルール)。「キャンセルしてよいか」「この回答は有効か」。業務が決める。間違えるとバグ。画面が変わっても答えは同じで、モデルの責務。

この2つを混ぜると、業務ルールがselectorやコンポーネントに散らばって、仕様変更のたびにコピペを探す羽目になる。だから判定はモデルに集約して、selectorは結果を見て整形するだけにする。じゃあその判定、物理的にどこに書くのか——モデルの振る舞いか、Viewに直書きか——はReactの判定ロジック、結局コンポーネントに直書きしちゃうよね - 4段階で整理した開発で4段階に整理した。

最初の迷いは、全部この一本から解ける

ここまで来ると、冒頭の手が止まる瞬間が片付く。

  • 「FEにモデル要る?」→ 入口の parse は基本要る(薄くていいし、型保証された自社APIなら省ける)。判定(ルール)があるなら育てる、なければ検証済みデータで終わり。
  • 「VMかACLか」→ ACLは入口の parse、VMは出口の selector。別レイヤーだから二択じゃない。両方、別の場所で要るだけ。
  • 「ドメイン層を切るべき?」→ 判定があるかどうか。なければ要らない。
  • 「この変換どこに置く?」→ 見せ方なら整形(selector)、ルールなら判定(モデル)。

panda-programさんがフロントエンドにビジネスルールは無いと書き、フロントエンジニアが「JSON色付け係」と自虐するのも、kuroeverydayさんがフロントのロジックは表示・検証がメインだと言うのも、この流れの上に置くと喧嘩しない。多くのフロントは確かに”色付け”=整形で、たまに混じる”判定”だけモデルに隔離できれば足りる。モデルが薄い、むしろ要らないのが正しい場面は多い。

結局、フロントの「モデル」とは何か

最初の問いに戻る。フロントの “モデル” って何なのか。この流れを通すと、答えはシンプルになる。

モデルとは、信用できない外部データ(API もユーザー入力も)を境界で一度 parse して得た、自分の都合の型の “検証済みの状態”。判定(ルール)があればそこに内包し、画面用の整形は持たない。

多くの場合は薄い plain object で足りるし、それで正しい。生の API は “信用できない外部”、モデルは “入力の正本”、ViewModel は “出力の見た目”。この3つを混ぜないだけで、だいぶ見通しがよくなる。

そしてモデルを含めてフロント全体を一文にすると、こうなる。

フロントエンドとは、信用できない外部データを検証済みの状態に変え、サーバが正本のものはキャッシュとして、画面が正本のものは状態として扱い、ドメインの判断はモデルに、画面の都合は整形に委ね、生の形を画面に晒さずに UI として表現し、操作を次の状態へ還し続ける営み。

この長い一文は覚えなくていい。要は冒頭の「状態をUIに変え、操作を次の状態へ還す循環」で、それだけ頭にあれば足りる。長い版は、その循環を一個ずつ展開しただけ。

設計で手が止まったら、僕はもうこの流れに戻ることにした。「今これは、検証なのか、整形なのか、判定なのか。状態の正本はどっちにあるのか」。Reactでhooksをどこで注入すべきか?コロケーション vs リフトアップの判断基準開発フロントエンドのValue Objectに複数のformatを生やすのを止めた話 - ドメイン整形と表示整形の境界線開発で書いたことも、結局この流れのどこかの話だったな、と今は思う。

フロントに “モデル” は要るのか。答えは「入口に薄く1枚、判定があれば育てる」。VMかACLかで悩んでたなら、それは入口と出口の話だった。

Thanks for reading!
ReactTypeScriptフロントエンド設計状態管理ドメイン駆動設計設計パターン