「モード」をコンポーネントに持たせない理由
こんにちは。今日は、AI開発で実際に起きた、とても重要な設計判断について書きます。
私はLife Atelierというサービスを公開しています。今回は、その新機能追加にまつわる話です。
はじめに
新しい画面を作ります。よく似たコンポーネントが、もう手元にあります。ゼロから作る理由がありません。
9割は使えます。でも、そのままでは使えません。
ここで、実務上の分岐点が来ます。新しく別のコンポーネントを作るか。それとも、既存を共通化してモードを足すか。
たいていは後者を選びます。コンポーネントを無闇に増やしたくないからです。
だが、この二択そのものが、最初から間違っていました。
実際に選んだのは、どちらでもありませんでした。新しく作るのでも、既存に条件分岐を足すのでもありません。呼び出す側が、必要な部品を選べばいいのです。
問題は「どのコンポーネントを使うか」ではありませんでした。「誰が違いを知るべきなのか」が問題だったのです。
モードを表示/非表示の機能として足すか、呼び出し側の選択に押し出すか——この記事は、その違いを書きます。
この記事をお勧めしない人
- 既存の設計を変えてまで最適化しようとは思わない人
- テストコストを定量的な言葉で説明できても、それで何かが変わるとは思っていない人
もし一つでも当てはまらないなら、読み進める価値があるかもしれません。
【思想衝突】
- Delivery Speed vs Future Stability
- Cognitive Simplicity vs Technical Control
① 問題提起:暗黙のコスト
「共通化して、モードを足す」——この判断は、単体では間違っていません。1つのモードを足すコストは、たしかにフラグ1つ分でしかありません。
だが、この判断は暗黙のコストを先送りしているだけです。設計を間違えたわけではありません。選択の副産物として、後から現れます。
ここで一つ、区別しておきたいことがあります。ある値に応じて見た目の一部を出し分けること自体は、何も悪くありません。問題になるのは、その値によってコンポーネントの振る舞いそのもの(内部の処理経路)が枝分かれする場合です。以下のコストは、後者にだけ当てはまります。
組み合わせは掛け算で増える
独立した真偽値が1つ増えるたびに、既存の真偽値すべてとの組み合わせが、理論上「到達可能な状態」になります。真偽値が2つなら4パターン、3つなら8パターン、n個なら2ⁿパターン。実際にその組み合わせすべてが意味を持つとは限りませんが、コード上は到達可能な状態空間として存在してしまいます。やるかやらないかは別として、検証すべき組み合わせの数自体は、足し算ではなく掛け算で増えます。画面の表示は、有則ととらえられることが多い
ある要素の表示/非表示が他の要素の配置に影響する実装(フレックスレイアウト等)は珍しくありません。判断としては「関係ないから検証しなくていい」と切り捨てる余地があっても、実務では【デフォルトで組み合わせがテストケースに含まれてしまいます】。実際に増えるのは、最も高コストな検証層
コンポーネント単体のロジックはユニットテストで検証できても、複数の部品が実際に組み合わさった最終的な表示結果は、最終的にはブラウザ上で確認するのが最も確実です。組み合わせのパターンが増えるほど、確認すべきケースの数も増え、その検証はロジック単体のテストより高コストになりがちです。
モードが1つ増えるたびに、実装コストはほぼ一定のまま、検証すべき組み合わせは掛け算で増えていきます。今日は誤差でも、この乖離は際限なく開いていきます。
② なぜ皆そう作るのか
新しい画面を素早く追加したい
既存コンポーネントへの真偽値プロパティ追加が「最短ルート」に見える。今動いている呼び出し元を、1つも壊さずに済ませたい
デフォルト値付きのオプション引数を足すだけで済ませる判断が定着する。後方互換性は保たれるが、分岐は内側に溜まっていく。機能追加というチケットの範囲を、リファクタリングという別スコープまで広げたくない
本来望ましい構造変更が、「今回はやらなくていい」として先送りされ続ける。
私の運営するサービスの開発でも、同じことが起きました。
閲覧専用の画面を追加する際、最初に検討されたのは「編集可能かどうかを示す真偽値プロパティを、既存コンポーネントに持たせる」という案でした。
要件は「今のこれに対して、この場合だけこう変えたい」という差分の言葉で語られるのが自然で、その言葉が一番先に思いつく解決策を、フラグ追加の方向へ引き寄せます。
③ 既存サービスと新機能
ここで、実際の開発内容をお伝えします。
Life Atelierは、技術的に言うと【テキストで書いたスクリプトを、ノードと依存関係のグラフとして視覚化するツール】です。Markdownを書くとレンダリングされた文書になるのと同じ感覚で、専用の記法でスクリプトを書くと、人生の出来事とその依存関係のグラフとして描画されます。
ここで、新たに新しい機能を追加することになりました。
【機能名】
ショーケース【目的】
オンボーディング。他人がすでに書いたスクリプトの完成形を、読み取り専用で見せることで、「自分でスクリプトを書けば、こういうグラフになる」という体験を、書く前のユーザーに伝えます。
オンボーディングが目的である以上、ショーケースで表示するGUIは、実際に自分のスクリプトを編集するときと【同じ】でなければ意味がありません。別に作った簡易プレビュー用の見た目を見せても、「本物はこう動く」という実感にはつながらないからです。
実装方針は、すでにあるアトリエのUIパーツ一式を流用します。しかし、たとえば参照軸ドックをそのまま流用すると、こんな問題がありました。参照軸ドックには軸の追加ボタンや編集パネルが最初から作り込まれており、そのまま持ち込むと、読み取り専用のはずのショーケースからも、他人のスクリプトを編集できてしまいます。

【アトリエ(編集画面)】
左下の「+」ボタンに注目してください。参照軸を追加できる、編集専用の操作です。

【ショーケース(閲覧専用画面)】
同じ位置に「+」ボタンがないことに注目してください。土台は同じでも、編集操作だけが存在しません。
この差分こそが、これから説明する設計判断の対象です。
ここで、件の設計判断がありました。
④ この設計判断は何を優先したのか
この設計判断が選んだのは、【Cognitive Simplicity】(部品の内側に分岐を持たせない単純さ)と【Future Stability】(モードが増えても検証コストが破綻しない構造)です。それは【Delivery Speed】(フラグ1つで即座に対応できる速さ)を犠牲にすることで成立しています。
優先した価値
【Cognitive Simplicity】
部品自身は、自分が今どの文脈で使われているかを一切知りません。渡された値をそのまま描画するだけの、単純な部品であり続けます。【Future Stability】
利用文脈がいくつ増えても、部品内部の検証コストは増えません。増えるのは、ユニットテストで安価に確認できる「呼び出し元の選択」の方だけです。
意図的に後退させた価値
- 【Delivery Speed】
フラグ1行を足すだけでは終わりません。呼び出し元ごとに、別の部品として組み立て直す初期コストを払います。
⑤ 実装でどう解決しているか
「この実装はどの価値を守るために存在するか」を語ります。
解決策1:表示層と編集層を、物理的に分割する
参照軸ドックの表示は、閲覧専用のページと、編集可能なページの両方で使われます。
普通なら、1つの部品にmodeのようなプロパティを足します。
この設計判断は違いました。
// 表示専用の部品(渡された値を描画するだけ。編集の状態を一切持たない)
export function AxesScale({ axes, selectedAxisId, onAxisTap }: AxesScaleProps) {
// 編集ドックの開閉状態も、下書きも、バリデーションも、ここには存在しない
}
// 編集専用の部品(表示専用の部品を内側に包んで使う)
export function AxesEditingLayer({ axes, ... }: AxesEditingLayerProps) {
const [dock, setDock] = useState<AxisOperationDockState>({ mode: 'closed' });
// 編集ドックの開閉・下書き・バリデーションは、すべてこちら側に閉じ込める
return (
<div>
{/* 編集用のUI */}
<AxesScale axes={axes} onAxisTap={openEdit} />
</div>
);
}閲覧専用のページはAxesScaleだけを呼び、編集可能なページはAxesEditingLayerを呼びます。「どちらを呼ぶか」を決めているのは、常に呼び出し元のページであり、部品自身ではありません。
責務の境界線を、はっきり引き直すとこうなります。
flowchart LR
subgraph 呼び出し側の責務
A[閲覧画面] -->|表示用の部品だけを渡す| C
B[編集画面] -->|表示用の部品+編集層を渡す| C
end
subgraph コンポーネントの責務
C[渡された状態を
そのまま描画するだけ]
end部品自身は、自分がどの画面で使われているか知りません。編集可能かどうかも判断しません。「編集不可だから」と自分で機能を隠すこともしません。守ろうとした価値は、【組み合わせを検証しなければならない場所を、部品の内側から、呼び出し元の固定配線へ移すこと】です。
解決策2:真偽値フラグではなく、渡すか渡さないかで機能を表現する
同じ判断は、単一の部品分割にとどまらず、より一般化された形でも繰り返されています。
普通なら、readOnlyのような真偽値プロパティを足します。
この設計判断は違いました。
// ❌ 却下された案:真偽値で内部分岐する
// <AtelierStage readOnly={true} />
// ✅ 採択された案:値を渡すか渡さないかだけで機能の有無が決まる
<AtelierStage currentPositionSlot={hasCurrentPosition ? <CurrentPositionOverlay /> : undefined} />AtelierStage自身は「編集可能かどうか」を一切知りません。渡されたcurrentPositionSlotがあれば描画し、なければ何もしません。判断根拠は「どちらの合成ルートから呼ばれているか」ではなく「渡すべき値が実際に存在するかどうか」であり、部品自身はその判断に関与しません。
これは、機能の有無を「状態」ではなく「構造」で表現する、ということです。「このコンポーネントは今readonlyモードです」という状態を持たせるのではなく、「この画面には、その部品をそもそも組み込んでいません」という構造で表現します。
守ろうとした価値は、【同じ原則を、1つのコンポーネント分割だけでなく、プロジェクト全体の設計規律として繰り返し適用すること】です。
ただし、親に押し出せば終わりではありません
分岐を子から親へ移せば、それで問題が消えるわけではありません。今度は「何をどう組み合わせてよいか」という知識が、呼び出し元に散らばります。これは複雑さを消したのではなく、単純に移動させただけです。子の複雑さを減らした代わりに、親の配線が複雑になる可能性があります。
flowchart TB
subgraph Before["Before: 分岐は子の内側"]
direction TB
P1[呼び出し元] --> C1["子コンポーネント
if mode === A
if mode === B ..."]
end
subgraph After["After: 分岐は親の配線"]
direction TB
P2["呼び出し元
どの部品を渡すか選ぶ"] --> C2["子コンポーネント
渡された値をそのまま描画"]
endだからLife Atelierでは、親側の構造も無制限に自由にはしません。セクション構成をproject-spec.yamlという単一の定義場所に集約し、1ファイルが一定の行数を超えたらリントで検知する仕組みを、他の全ファイルと同じ基準で適用しています。分岐を移動しただけでは、設計の改善とは言えません。移動先の複雑さまで制御できて、初めて成立します。
複雑さを消したのではなく、検証可能な場所へ移動した——この記事が実際に語っているのは、それだけです。この考え方は、Reactのコンポーネントに限りません。APIの振る舞い、ドメインロジック、状態機械の分岐にも、同じ形で当てはまります。
⑥ この設計は誰向けか
向いている
- UIコンポーネントに限らず、何らかの「モード」を持たせることを検討している人(表示の出し分けに限らず、APIの振る舞い分岐、処理ロジックの条件分岐なども含みます)
- E2Eテストの本数・複雑さに、実際に苦しんでいる人
向いていない
- モード間の差分が本当に些細(1行のCSSの違い等)で、今後も増える見込みがない人
- 既存コンポーネントがすでに複数の無秩序なモードで肥大化していて、今回追加する分だけを綺麗に切り出せない人。この記事は「次の1つ」の判断を扱っており、既存の混乱の解消は範囲外です
判断の基準は、「条件分岐するな」ではありません。「その条件分岐が、コンポーネントの責務そのものを変えるなら、分離を検討しろ」です。見た目の微調整に留まる差分にまで、この原則を適用する必要はありません。
「向いている」に1つでも当てはまったなら、この判断はあなた自身の現場にも起きている、あるいはこれから起きるはずです。次に問うべきは、Life Atelierがどちらを選んだか、ではありません。【あなたなら、どちらを選ぶか】、です。
⑦ 思想の衝突とあなたの選択
その選択は「正しい」わけではありません。
ある価値観のもとで「一貫している」だけです。
Delivery Speedを優先すれば、フラグを足します。今日が一番早く終わります。
Future StabilityとCognitive Simplicityを優先すれば、呼び出し側に選ばせます。今日は少し遅くなります。
そしてTechnical Control(透明性・責務の明確さ)も無関係ではありません。「渡すか渡さないか」で機能を表現する設計は、コンポーネントが何を受け取り、何を描画するかが型定義だけで見渡せるという点で、Technical Controlを優先する選択でもあります。一方でモードフラグには、「1つの部品の型定義を見れば、取りうる全モードが分かる」という発見しやすさ(Discoverability)があります。分割すると、全体像を把握するには呼び出し元を横断する必要があり、この発見しやすさは犠牲になります。フラグ方式にも、こうした合理的なメリットは確かにあります。
どちらが正しいかを、この記事は決めません。決められるのは、あなただけです。
設計とは、何を便利にするかではありません。
どの価値観を、どの順番で優先するかです。
モードを1つ、既存のコンポーネントに足しますか。
それとも、呼び出し側に選ばせますか。
Delivery Speed、Future Stability、Cognitive Simplicity、Technical Control——同じ設計問題でも、優先する価値が変われば、合理的な答えも変わります。