判定ロジックって、結局コンポーネントに直書きしちゃうよね。
{order.status !== 'shipped' && <CancelButton />} みたいなやつ。純粋関数に切り出せばテストできる、再利用もできる——分かってる。分かってるんだけど、毎回その場の JSX に書いて、ちょっとした罪悪感だけ残る。
この罪悪感に決着をつけたい。結論から言うと、抽出するかインラインで書くかは二択じゃなくて、4段階のグラデーション。そして「コンポーネントに直書き」は、多くの場面でちゃんと正解。だけど1個だけ、見逃すと地味に効いてくる罠がある。その線引きの話。
判定(ルール)と整形(見せ方)を分ける、という前提の話はフロントエンドの「モデル」って結局何なのか、定義し直してみた開発
で書いた。今回はその “判定” を、じゃあ物理的にどこに書くの、という続き。
抽出かインラインかの二択は、しんどい
判定をモデルの振る舞い(純粋関数や集約のメソッド)に置けば、テストできるし、1箇所に集まるし、再利用も効く。教科書的には正しい。
でも実際にやると、コンポーネントの中で order.status を見て出し分けたいだけなのに、わざわざ別の場所に関数を作りに行くのが大げさに感じる。で、結局 JSX に直書きする。この「理想は分かるけど続かない」が、二択で考えてるせいで起きてる。
純度で先に決めるとだいたい破綻する。だから段階で考える。
4段階のグラデーションで考える
「注文をキャンセルできるか」という同じ判定を、置き場所だけ変えていくとこうなる。Lv0 から Lv3 まで、一段ずつ。
Lv0 - JSXに直書き
その場の JSX に条件を埋める。単発で・自明なら、これで十分。
{order.status !== 'shipped' && <CancelButton />}
Lv1 - コンポーネント先頭の named const(デフォルト推奨)
同じ条件に名前をつけて、コンポーネントの頭に出すだけ。JSX が読みやすくなるし、後で抜くのも楽。
const canCancel = order.status !== 'shipped' && order.status !== 'delivered'
// ...
return canCancel ? <CancelButton /> : null
Lv2 - 同ファイルの純粋関数(テストしたくなったら)
引数で受け取る純粋関数にする。これで render なしに単体テストできる。
const canCancel = (o: Order) => o.status !== 'shipped' && o.status !== 'delivered'
Lv3 - モデルの振る舞い / policyモジュール(別ファイルからも使うなら)
集約のメソッドにするか、order-policy.ts みたいな別ファイルに切り出す。複数の画面から同じ判定を呼ぶならここまで上げる。
order.canCancel() // 集約のメソッド
// または canCancel(order) を order-policy.ts に置いて import
Lvを上げるほどテストしやすく・再利用しやすくなるけど、コストも上がる。ポイントは、デフォルトは Lv0〜1 で全然いいということ。あなたが「コンポーネントに書いちゃう」と言ってるのは Lv0/Lv1 にいるってことで、それ自体は健全。
ただし Lv3(モデルの振る舞い)まで上げていいのは、それが「判定(業務ルール)」のときだけ。同じ plan === 'free' でも、「無料プランは有料機能を使えない」は業務ルールだからモデルでいい。でも「無料プランのバッジは灰色にする」は判定じゃなく整形(デザイン都合の見せ方)で、いくら再利用しても view 側に置く。せいぜい Lv2(純粋関数や selector)止まりで、モデルには入れない。整形をモデルに混ぜると、見た目の都合がドメインに侵食するから。判定か整形かの見分けそのものはフロントエンドの「モデル」って結局何なのか、定義し直してみた開発
で書いた。
正直、この判定か整形かの見分けはめっちゃ難しい。「不具合じゃないけど不都合」なグレーが大半で、ハッキリ決まらないことの方が多いんだよね。
でも完璧に分類しようとしなくていい。抽出するか(Lvを上げるか)は、次に話す観測サインで決まるから、判定/整形がグレーでも手は止まらない。ラベルが効くのは Lv3(モデルに入れるか)の一点だけで、迷うならモデルには入れず view 側に置く。後でモデルに上げるのは楽だけど、太らせたモデルから戻すのは面倒だから。
そのうえで大事なのが「いつ一段上げるか」。
一段上げる引き金は、純度じゃなく観測できるサイン
「テストできるから」みたいな純度を理由に上げると、続かないし宙に浮く。代わりに、観測できるサインが出たら一段上げる。
- 2箇所目が来た。重複が抽出を強制する。Lv2へ。
- テストを書きたくなった。「テストしたい」と感じた時点で、それは独立したロジックの証拠。Lv2へ。
- 条件式に業務の名前がつく(
canCancel、isUnpaid)。ルールが隠れてるサイン。Lv2〜3へ。
どれも来てないなら、上げなくていい。先回りで Lv3(モデル層・class・DI)を作るのが、一番やりがちな過剰設計。重複が来るまで待つ、という遅延戦略がいちばん疲れない。
モデルに上げるなら、名前は「データの条件」で
さっき「Lv3 に上げていいのは判定だけ、整形はダメ」と言った。でも、グレーなやつ(「3日以内なら NEW」みたいなの)も、“データの条件” の部分だけ抜けばモデルに置ける。鍵は名前。Lv3 に上げるとき、名前に UI 由来の言葉を絶対に入れない。これだけで、業務かデザインかの決着をつけなくてよくなる。
// ❌ UIの結果で名付ける → モデルが画面(バッジ・色)を知ってしまう
order.shouldShowNewBadge() // "NEWバッジ" という画面都合がドメインに侵食
deal.isGrayedOut() // "グレーアウト" も同じ
// ⭕️ データの条件で名付ける → ただのデータの事実。テストもできる
order.isRecent() // 3日以内か
user.isWithdrawn() // 退会したか
deal.isUnanswered() // 未回答か
order.isRecent() をモデルに置いて、「recent なら NEW バッジを出す」はコンポーネントが決める。isRecent(データの事実)と「それを NEW で見せる」(画面の都合)が分かれて、前者はモデル、後者はコンポーネントに収まる。
効くのはここ。「3日以内なら NEW」が業務都合かデザイン都合か、決着をつけなくてよくなる。UIの結論(NEWバッジ)をモデルに書かないから、その問いごと消える。モデルに残るのは “3日以内か” というデータの事実だけで、それは誰が見ても事実。shouldShow〇〇 をやめて is〇〇(状態を問う名前)にする、それだけ。
ひとつ境界がある。これが効くのは入力がデータ(日付・status・有無)のときだけ。「アコーディオンが開いてるか」みたいな view-state 由来の分岐は、モデルがその状態を知らないから振る舞いにできない。コンポーネントに残す。
コンポーネントに直書きでいい、ただし1個だけ罠
コンポーネントローカルで・単発で・自明な分岐は、Lv0 で正解。テストしない判定をわざわざ抽出しても、テスト可能性というメリットが宙に浮くだけ。だから「直書きが多い」のは、現実的には悪いことじゃない。
罠は1個だけ。見た目の分岐に見えて、実は業務ルールなやつ。たとえば「無料プランのユーザーには、有料機能のボタンを出さない」。一見ただの出し分けだけど、これは “無料プランは有料機能を使えない” という課金の決定で、その plan === 'free' が Lv0 でコンポーネントに直書きされたまま、ナビ・設定画面・機能ゲート…と静かにコピペされていく。そして「トライアル中も無料プラン扱いにする」みたいな仕様変更が来た瞬間、全コピー先を探す羽目になる。
正直、僕も大半は Lv0〜1 で書く。で、「あれ、この plan === 'free' 書くの3回目だな」で、ようやく Lv2 に抜く。先回りで綺麗にやろうとすると続かないから、3回目の自分を信じるくらいがちょうどいい。
抽出=大層なモデル層、じゃない
「コンポーネントに書いちゃう」の正体は、たぶん抽出=domainフォルダを作って、class を生やして、DI して…という重いイメージでコスト高に感じてるから。でも抽出は層を作らなくていい。コンポーネントの真上に純粋関数を1個置くだけでいい。
// 同じファイルの上の方。renderなしでテストできるし、後でpolicyに昇格も楽
const canCancel = (order: Order) =>
order.status !== 'shipped' && order.status !== 'delivered'
export function OrderActions({ order }: Props) {
return canCancel(order) ? <CancelButton /> : null
}
// テストも render 不要。入力と期待値だけ
test('出荷後はキャンセル不可', () => {
expect(canCancel({ status: 'shipped' } as Order)).toBe(false)
})
これなら Lv0 からの距離がほぼゼロ。Martin Fowler の言う Humble Object(テストしにくい View からロジックを抜いて、テストできる側に寄せる)も、Kent C. Dodds の colocation(関連するものは近くに置く)も、これで両取りできる。domainフォルダに引っ越すのは、別ファイルからも使いたくなった時(Lv3)でいい。
「コンポーネントに直書き」と「テスト可能な純粋関数」は、同じファイルに置けば実はほぼ同じコスト。離れた層に追い出すから重く感じるだけ。
まとめ、デフォルトはLv1
落とし所はこう。
- デフォルトは Lv1(コンポーネント先頭の named const)。JSXべた書きより一段だけ上げておく。
- そうすると「2箇所目」「テスト書きたい」が来た瞬間、純粋関数(Lv2)に持ち上げるのがタダになる。
- 先回りで Lv3 まで抽出もしない、JSXにベタ書き(Lv0)で散らかすこともしない。その中間が一番疲れない。
判定をモデルに置くかコンポーネントに書くか、で固まってたなら——二択じゃなくて、Lv1 を起点に、サインが出たら一段ずつ上げるグラデーション。罠は「業務ルールがコンポーネントに直書きされて静かに増殖する」やつだけ気をつける。
判定と整形の “分類” の話はフロントエンドの「モデル」って結局何なのか、定義し直してみた開発
、コンポーネントとロジックの分け方はHooks時代でもPresenter分離が有効な理由|Storybook・テスト・分業の観点から開発
で書いてるので、合わせてどうぞ。