フロントエンドの「モデル」って、結局何なんだろう。
正直、多くの画面は 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 が変わるたびにあちこちが壊れる。
だから境界で一度、実行時に検証して自分の都合の型に翻訳するゲートを通す。zod で parse する、あの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かで悩んでたなら、それは入口と出口の話だった。