コンテンツにスキップ

第6章 適用のしかた

この手法をどこから始めるかは、すでにコードがあるか、これから書くかで変わります。

状況 入り口
既存のページをほぐしたい God Page を観察する
新しい画面をこれから作る Island から作る

どちらの場合も、5つの単位を最初から全部そろえようとしません。必要になった境界だけを、必要になった順に引きます。

God Page とは、状態が絡み合い、どの状態が誰に使われているのかを追えなくなったページのことです。

次のような形をしています。

  • ページの最上位に、状態の宣言が10個も20個も並んでいる
  • そこから下へ、props が何段も渡されている
  • どれか1つを変更すると、どこが壊れるか予測できない
  • 「この値、まだ使われているのか?」を調べるのに全文検索が要る

こうなる原因は README で扱いました。要点は、個々には正しい判断の積み重ねだということです。共有が必要になるたび共通の親へ上げると、上げ先が機能の境界ではないため、状態は最上位へ集まります。

リファクタリングは、このページを観察するところから始めます。 いきなり分割せず、まず何が絡み合っているのかを見えるようにします。

1. 状態をすべて書き出す
2. それぞれを「誰が読むか」「誰が変えるか」で記録する
3. 同じ顔ぶれからしか触られない状態をまとめる → Island の候補
4. 複数のまとまりから読まれる状態を拾う → Bridge の候補
5. 画面全体に効く状態を拾う → Scaffold
6. 画面を離れても要る状態を拾う → Application
7. Island の内部を Component と Part へ分ける

3から6は、第2章 状態所有 の判断手順を逆から使っています。所有者を決めてから状態を置くのではなく、すでに置かれている状態から所有者を割り出します。

手順7だけは、状態を起点にしていません。ここまでの手順は状態を追って境界を引くので、状態を持たない要素は最後まで分類されずに残ります。 Island の切り出しが終わったら、その内部をもう一度見て、ComponentPart へ分けます。判定は第1章のとおり、自分で状態を持つかどうかです。

この順序は守ってください。Island の境界が決まる前に内部を分け始めると、あとで Island ごと動かすときに作り直しになります。

1つのファイルに次の状態が並んでいるとします。

tasks タスク一覧(サーバーから取得)
activities アクティビティ一覧(サーバーから取得)
selectedTaskId 選択中のタスク
draggingTaskId ドラッグ中のタスク
collapsedColumnIds 折りたたまれている列
activityFilter アクティビティの絞り込み
chatMessages チャットのメッセージ
chatDraft チャットの入力中テキスト
isChatOpen チャットパネルの開閉
selectedTab 右パネルのタブ
assigneeFilter 担当者フィルタ

手順2の前に — ここから先は「機能のまとまり」を単位に進めます。まとまりは、コードの構造から探すのではなく、画面を見て、名前を付けられる領域に区切るところから作ります。カンバンの領域、右側のパネル、下部のチャット。チームで画面の話をするときに使っている呼び方が、そのまま使えます。

この区切りは暫定で構いません。手順3で状態の顔ぶれを見たときに、分けすぎていたことも、まとめすぎていたことも分かります。正解を決めてから表を作るのではなく、表を作って区切りを直します。

手順2 — それぞれを、誰が読み、誰が変えるかで整理します。ここでいう「誰」は、いまのコードの構造ではなく、いま区切った画面上の機能のまとまりです。

状態 読む機能 変える機能
tasks カンバン / タスク詳細 データ取得
activities アクティビティ データ取得
selectedTaskId カンバン / タスク詳細 カンバン
draggingTaskId カンバン カンバン
collapsedColumnIds カンバン カンバン
activityFilter アクティビティ アクティビティ
chatMessages チャット チャット
chatDraft チャット チャット
isChatOpen 画面レイアウト / チャット ヘッダー
selectedTab 画面レイアウト 右パネル
assigneeFilter カンバン / アクティビティ ヘッダー

手順3 — 同じ機能だけが読み書きしている状態をまとめます。これが Island です。

KanbanIsland ← draggingTaskId / collapsedColumnIds
ActivityIsland ← activityFilter
ChatIsland ← chatMessages / chatDraft
TaskDetailIsland ← (固有の状態なし)

TaskDetailIsland は自分の状態を持ちませんが、Island として切り出します。タスク詳細は独立した機能であり、あとから「編集中かどうか」のような状態を持つようになるからです。状態の有無ではなく、機能として独立しているかで判定します。

手順4 — 複数のまとまりから読まれている状態を拾います。これが Bridge です。

TaskSelectionBridge ← selectedTaskId (カンバン + タスク詳細)
AssigneeFilterBridge ← assigneeFilter (カンバン + アクティビティ)

読む顔ぶれが違うので、Bridge は2つに分かれます。1つにまとめると、担当者を切り替えただけでタスク詳細まで再描画の対象に入ります。

手順5と6 — 残りを振り分けます。

ProjectScaffold ← isChatOpen / selectedTab (画面の構成を変える)
Application ← tasks / activities (サーバーから取得したデータ)

tasksKanbanIsland に持たせない理由は、タスク詳細も同じデータを読むからです。Island が持つのは「この機能がどう表示しているか」であって、データそのものではありません(第2章 状態所有 のアンチパターン)。

結果 — 論理構造は次のようになります。

ProjectScaffold isChatOpen / selectedTab
├─ AssigneeFilterBridge assigneeFilter
│ ├─ TaskSelectionBridge selectedTaskId
│ │ ├─ KanbanIsland draggingTaskId / collapsedColumnIds
│ │ └─ TaskDetailIsland
│ └─ ActivityIsland activityFilter
└─ ChatIsland chatMessages / chatDraft

11個の状態が、1箇所から6箇所へ分かれました。カンバンでカードをドラッグしても、再描画されるのは KanbanIsland の内側だけです。

selectedTabScaffold か、右パネルの Island か。 右パネルがタスク詳細とアクティビティを切り替えるだけの入れ物なら、これは「どの Island を画面に出すか」の判断であり、Scaffold のものです。右パネル自体が独立した機能を持つなら、Island になります。その要素が機能なのか、置き場所なのかで決まります。

isChatOpenScaffoldChatIsland か。 チャットが開いているかどうかは、チャット自身の見た目だけでなく、隣接する領域の幅も変えます。画面のレイアウトに影響するので Scaffold です。もしチャットがオーバーレイとして他の領域に影響しないなら、ChatIsland が持って構いません。

assigneeFilterScaffold に置いてはいけないのか。 置いても動きます。しかし読んでいるのはカンバンとアクティビティだけなので、Scaffold に置くと「画面全体で意味を持つ状態」の中に、2機能の都合でしかない値が混ざります。画面が育つほど、Scaffold は誰が使うか分からない状態の置き場に戻っていきます。

この分類ができても、書き換えは1つずつ進めます。

1. いちばん独立している機能を1つ選ぶ(上の例では ChatIsland)
2. その状態と更新処理を Island へ移す
3. 動作を確認する
4. 次の機能へ移る
5. Island が出そろってから、Bridge を作る
6. 最後に、各 Island の内部を Component と Part へ分ける

手順2で1つの状態を移す具体的な作業は、第2章 状態所有 の「所有者を移すとき」にあります。直すのは、状態の宣言位置・更新処理の置き場所・参照側の受け取り方の3つです。

手順3で見るのは、変わってはいけないものが変わっていないかです。

変わってはいけない 変わってよい
その状態を使う画面の見た目 再描画される範囲
操作したときの結果 状態が宣言されている場所
状態が保持される期間 props として渡っていく経路

再描画の範囲が狭くなるのは、この作業の目的そのものです。一方、画面を離れて戻ったときに値が消えるようになった、という変化は行き過ぎです。移した先の生存期間が元より短いことを意味します。

Bridge を最後に回すのは、どの状態が本当に共有されているかは、Island を切り出してみるまで確定しないからです。観察の段階で「共有されている」と見えた状態が、切り出してみると片方でしか使われていなかった、ということが起こります。

新しい画面を作るときは、Island を1つ作るところから始めます。

新しい画面の要件は、たいてい「何ができる画面か」という形で来ます。それは機能であり、Island の単位です。

他の単位から始めると、次のようになります。

Scaffold から始めると。 機能が決まっていないうちに、画面の枠と状態の置き場だけができます。そこへ状態を足していくと、機能の境界を引かないまま状態が集まります。これは God Page の作り方そのものです。

PartComponent から始めると。 部品は増えますが、画面が何をするのかは決まりません。あとから機能を組み立てる段になって、部品の粒度が合わないことに気づきます。

1. その画面でできることを1つ挙げ、Island を1つ作る
状態はすべてその Island の中に置く
2. 画面に置くために Scaffold を作る
この時点では Island を1つ配置するだけ
3. 2つ目の機能が必要になったら、2つ目の Island を作る
4. Island が大きくなったら、独立した機能が混ざっていないか見る
混ざっていれば分割する
5. 2つの Island が同じ状態を読み始めたら、Bridge を作って移す
6. Component と Part は、Island の中身を書いていて
必要になった時点で切り出す

手順4の「独立した機能が混ざっている」は、第1章 5つの境界Island の判定基準で見分けます。その部分だけを別の画面へ移したとき、機能として成立するかです。

状態からも分かります。その Island が持つ状態が2つの集合に分かれていて、互いを読んでいないなら、機能も2つに分かれています。

Bridge を先に作らない。 「あとで共有しそうだから」と作った Bridge は、たいてい値を1つしか持たないまま残ります。共有が実際に起きてから作れば、何を持つべきかが確定した状態で作れます。

ComponentPart を先に作らない。 「ボタンとカードとバッジを用意してから画面を組む」という順序は、部品の仕様を使う前に決めることになります。Island を書いていて同じ形が2回目に出てきたら切り出す、で間に合います。

ただし、デザインシステムを別途整備している場合は例外です。その場合、共有の Part は画面より先に存在します(第7章 ディレクトリ構成と共有)。

この手順の要点は、小さく作って分けていくことです。逆向き、つまり最初に5つの単位をすべて用意して埋めていく進め方は取りません。

Island が1つだけの画面は、未完成ではありません。機能が1つならそれが正しい構造です。2つ目の機能が現れたときに初めて、Island を分ける理由と、Bridge が要るかどうかが決まります。

境界は、必要が生じてから引きます。

すでに Atomic Design を採用している場合

Section titled “すでに Atomic Design を採用している場合”

既存の atoms/organisms/ を作り直す必要はありません。2つの手法は分類の基準が違うので、置き換えではなく足し算になります(第5章 既存の設計手法との比較)。

1. organisms/ の中から、状態を持ち、機能として独立しているものを探す
→ それが Island の候補
2. 見つけたものを islands/ へ移し、関連する状態をそこへ集める
3. atoms/ と molecules/ はそのまま残す
呼称も変えなくてよい
4. Island 同士が状態を共有していたら、Bridge を作る

Atomic Design が答えていなかったのは「状態を誰が持つか」だけです。そこに IslandBridge を足せば足ります。部品の分類はデザインシステム側の語彙として残し、状態の分類だけを新しく引く、という使い分けになります。

会話するときは、どちらの語彙で話しているかを明示してください。「この organism は Island にしよう」という文は成立しますが、両方の名前を混ぜたまま議論すると、何を決めているのか分からなくなります。