コンテンツにスキップ

第1章 5つの境界

この章では、Scaffold / Bridge / Island / Component / Part の5つの単位について、それぞれが何を境界としているのか、どう判定するのかを説明します。

以降のすべての章は、ここでの定義を前提にします。

Scaffold は、ひとつの画面を成立させる境界です。担当するのは Screen Boundary、つまり「ここからここまでが1つの画面である」という切れ目です。

画面全体の構造と、画面単位で意味を持つ状態を管理します。

ProjectScaffold
├─ Navigation
├─ MainContent
└─ Inspector

Scaffold が所有するのは、その画面にいる間だけ意味を持ち、かつ画面のほぼ全体に影響する状態です。

SelectedTab
IsSidebarOpen
CurrentViewMode

IsSidebarOpen を例にとります。サイドバーが開いているかどうかは、サイドバー自身の見た目だけでなく、隣接する主要領域の横幅も変えます。つまり画面のレイアウト全体に影響します。

この状態を主要領域の内側に置くとどうなるか。サイドバーの開閉ボタンから、主要領域の内側へ通知を送ることになります。関係が逆転します。

  • Island を画面上のどこに配置するかを決める
  • 画面全体のライフサイクル(画面に入ったとき、離れるときの処理)を管理する
  • 画面サイズに応じて Island 同士の配置を変える(→ 第4章 レスポンシブ設計)

特定の Island 同士だけで必要になる協調状態を、Scaffold へ集める必要はありません。

たとえばSNSのタイムライン画面で、タイムラインとスレッドだけが「開いている投稿」を共有しているとします。この値を Scaffold に置くと、画面に後から追加される他の Island からも見える場所に、2つの Island の都合でしかない値が置かれることになります。画面が育つほど、Scaffold は「誰が使っているのか分からない状態の置き場」になっていきます。

このような責務は Bridge へ分離します。

逆に、複数の画面が読む状態も Scaffold の責務ではありません。 その画面にいる間だけ意味を持つ値ではないため、置き場所は Application 層です(→ 第2章 状態所有)。

Flutter には Scaffold というウィジェットがあります。AppBarDrawerFloatingActionButton を配置するためのものです。

この手法の Scaffold は、特定のフレームワークのウィジェットではなく、責務の分類名です。Flutter で実装する場合、この手法の Scaffold が内部で Flutter の Scaffold ウィジェットを使うことはありますが、両者は別のものです。

Bridge は、複数の Island を協調させる論理的な境界です。担当するのは Coordination Boundary、つまり「これらの機能は連動している」という関係の切れ目です。

共有状態や、Island 間の関係を管理します。

Bridge 自身にはレイアウト能力がなく、視覚的な表現も持ちません。 枠線も余白も背景色も持たず、子の配置順も決めません。

論理構造には現れ、描画構造には現れない

Section titled “論理構造には現れ、描画構造には現れない”
Logical Structure
TimelineScaffold
└─ PostSelectionBridge
├─ TimelineIsland
└─ ThreadIsland

描画構造には、Bridge は現れません。

Visual Structure
TimelineScaffold
├─ TimelineIsland
└─ ThreadIsland

この2つの図の差が Bridge の性質そのものです。詳しくは 第3章 論理構造と描画構造 で扱います。

タイムラインとスレッドの両方から参照される SelectedPostId は、どちらか一方の Island ではなく Bridge が所有できます。

PostSelectionBridge
├─ SelectedPostId
├─ TimelineIsland
└─ ThreadIsland

SelectedPostIdTimelineIsland が所有した場合、ThreadIslandTimelineIsland の存在を前提にすることになります。タイムラインを経由せず、検索結果から直接スレッドを開けるようにしたくなったとき、ThreadIsland まで書き換えが波及します。Bridge に置けば、ThreadIsland は「開く投稿のIDを受け取る」とだけ決まっていればよく、誰が選んだかを知る必要がありません。

Bridge は描画構造に現れませんが、実装上は何らかの形で存在します。

使うのは、props を1段ずつ渡す代わりに、ある範囲の内側からなら直接読める場所に値を置く仕組みです。呼び名は違っても、宣言的UIフレームワークには必ず用意されています。

props で1段ずつ渡すと、こうなります。

Bridge(値を持つ)
└─ 中間の要素(値を素通しするだけ)
└─ Island(値を使う)

範囲に置いて直接読ませると、こうなります。

Bridge(値をこの範囲に置く)
└─ 中間の要素(値を知らない)
└─ Island(直接読む)

途中の要素が、自分では使わない値を素通しする必要がなくなります。props のバケツリレーが起きるのは、渡したい先が深いのに、渡す手段が親から子への1段しかないためです。

フレームワークごとの呼び名は次のとおりです。置く側と読む側の両方を挙げます。Bridge を実装するとは、この2つを書くことです。

フレームワーク 値を置く(Bridge 側) 値を読む(Island 側)
React Context の Provider で囲む 内側から useContext
Flutter InheritedWidget(Provider などのパッケージ経由が一般的) 配下から .of(context)
Vue provide 配下から inject
SwiftUI @Observable なオブジェクトを .environment で渡す @Environment で受け取る

状態管理ライブラリを使う場合は、スコープ付きのストアを1つ作り、その範囲を Bridge の範囲と一致させます。

どの仕組みも、**「指定した範囲の内側なら、どこからでも読める」**という点が共通しています。親から子へ1段ずつ渡す方法との違いはそこだけです。

いずれの場合も、Bridge は「値を配る仕組み」として存在し、「描画される箱」としては存在しません。Provider はコード上のノードとして書かれますが、余白も枠も生まないため、描画構造には現れません。

GoF の Bridge パターンとは別概念

Section titled “GoF の Bridge パターンとは別概念”

デザインパターンの Bridge は、抽象と実装を独立に拡張できるよう分離するパターンです。この手法の Bridge は、複数の機能をつなぐ協調境界を指し、目的も構造も異なります。名前が同じであること以外に関係はありません。

Bridge は、共有する状態のまとまりごとに作ります。1つの画面に1つ、という制限はありません。

3つの Island を持つプロジェクト画面で、次のような共有があるとします。

  • カンバンとタスク詳細が「選択中のタスク」を共有する
  • カンバンとアクティビティが「表示対象の担当者」を共有する

このとき Bridge は2つになります。

ProjectScaffold
├─ TaskSelectionBridge
│ ├─ KanbanIsland
│ └─ TaskDetailIsland
└─ AssigneeFilterBridge
├─ KanbanIsland
└─ ActivityIsland

KanbanIsland が両方の Bridge に現れていますが、問題ありません。Bridge は描画構造に現れないので、1つの Island が複数の Bridge から値を受け取ることができます。実装上は、2つの Provider が入れ子になり、その内側に KanbanIsland が置かれます。

TaskSelectionBridge
└─ AssigneeFilterBridge
├─ KanbanIsland
├─ TaskDetailIsland
└─ ActivityIsland

入れ子の順序に意味はありません。Bridge 同士は互いを知らないためです。

1つの Bridge が、関係のない複数の状態を持ち始めたら分割します。

「選択中のタスク」と「表示対象の担当者」を1つの ProjectBridge にまとめると、担当者を切り替えただけで、選択状態しか使っていない TaskDetailIsland まで再描画の対象に入ります。Bridge は更新境界でもあるため、無関係な状態を同居させると、そのぶん更新の範囲が広がります。

その Bridge を参照している Island の組み合わせが、状態ごとに違っているなら分割する。 これが判断の基準です。

Scaffold空間を構成するものなら、Bridge関係を構成するものです。

同じ画面に置かれた2つの Island があるとき、「どこに並べるか」を決めるのが Scaffold、「何を共有しているか」を決めるのが Bridge です。

Island は、ひとつの機能とその状態をまとめる境界です。担当するのは Feature Boundary、つまり「ここからここまでが1つの機能である」という切れ目です。

KanbanIsland
├─ KanbanState
├─ ColumnHeader
└─ TaskCard

Island 内部の状態変更は、可能な限りその Island 内で完結させます。

カンバンでカードをドラッグしている最中の位置、どの列が折りたたまれているか、といった状態は、カンバンの外から見て意味がありません。外へ出すと、外側がカンバンの内部構造を知ることになります。

状態が Island の内部で完結しているということは、その状態が変わったときの再描画も Island の内部で止まるということです。

ProjectScaffold
├─ KanbanIsland ← 更新
├─ ActivityIsland
└─ ChatIsland

カンバンの状態だけが変化した場合、ActivityIslandChatIsland まで同じ更新境界に含める必要はありません。

ここでいう更新境界とは、ある状態が変化したときに再描画が及ぶ範囲のことです。多くのフレームワークでは、状態を持つコンポーネントとその配下が再描画の対象になります。したがって状態をどこに置くかが、そのまま再描画の広がりを決めます。Island を機能の境界として切ると、それがそのまま更新の境界になります。

更新境界には、もう1つの意味があります。再描画が及ぶ範囲は、そのまま「その状態を変えたときに動作を確認すべき範囲」でもあります。 状態を Island の内側へ閉じ込めれば、確認する場所も Island の内側で済みます。修正の影響範囲が読めないという問題は、状態が広い場所に置かれていることの裏返しです。

ある部分が Island かどうかは、次の問いで判定します。

  • その部分だけを別の画面へ移したとき、機能として成立するか。 カンバンは、プロジェクト画面から取り出してダッシュボードへ置いても、カンバンとして成立します。
  • その部分の状態が変わったとき、他の機能が再描画される必要があるか。 必要がないなら、更新境界として切る価値があります。

大きさは基準になりません。画面の8割を占めるカンバンも Island ですが、隅に置かれた小さな通知センターも Island です。

Island という名前は、Web の Islands Architecture における Hydration の境界から着想を得ています。

ただし、由来が同じであることと、同じものであることは違います。Islands Architecture の island は「ブラウザへJavaScriptを送る単位」であり、UI Boundary Architecture の Island は「機能と状態の独立境界」です。詳しくは 第5章 既存の設計手法との比較 で扱います。

Component は、自分で状態を持ち、ユーザーの操作に応じて振る舞う境界です。担当するのは Interaction Boundary、つまり「ここが操作を受け付ける単位である」という切れ目です。

Button
Checkbox
TextField
Dropdown
Slider

判定の基準: 自分で状態を持つか

Section titled “判定の基準: 自分で状態を持つか”

ComponentPart を分けるのは、その要素が自分で状態を持つかどうかです。

自分で状態を持つ → Component
持たない → Part

Button は押されているかどうかを自分で持ちます。Checkbox はオンかオフかを、TextField は入力中の文字列を、Dropdown は開いているかどうかを持ちます。いずれも Component です。

一方 Card は何も持ちません。表示する内容も、枠線の色も、影の濃さも、すべて外から受け取ります。CardPart です。

なぜ状態の有無で判定できるのか

Section titled “なぜ状態の有無で判定できるのか”

Component が担うのは Interaction Boundary です。それなのに判定の基準が状態なのは、噛み合っていないように見えるかもしれません。

ここでいう状態とは、インタラクションに必要な内部変数のことです。押されているか、開いているか、何が入力されたか。フレームワークが用意している状態管理の仕組み(React の useState、Flutter の setState、SwiftUI の @State など)で保持する値だと考えてください。

これらの変数が生まれる理由は、ほとんどがユーザーの操作です。内部変数を持つということは、ユーザーの操作を自分で受け止めて覚えているということであり、それがそのまま「操作を受け付ける単位」を意味します。

この基準の利点は、迷ったときにコードを見れば決まることです。解釈の余地がある基準では、同じ要素でも人によって結論が変わります。内部変数が1つでもあれば Component、と決めておけば、誰が見ても同じ結論になります。

状態を持たせるかどうかは設計の選択

Section titled “状態を持たせるかどうかは設計の選択”

判定の基準が状態なので、同じ見た目の要素でも、設計次第で Component にも Part にもなります。

Avatar を例にします。

Avatar が読み込み状態を自分で持つ
→ 読み込み中はプレースホルダーを出す
→ Component
Avatar が読み込み状態を持たない
→ 何を表示するかは外が決め、Avatar は受け取ったものを描く
→ Part

どちらを選んでも構いません。重要なのは、Part にしたいなら状態を持たせないと決めておくことです。「Part のつもりだったが、いつのまにか状態が3つ増えていた」という育ち方を防ぐために、この基準を使います。

一般語の「コンポーネント」との区別

Section titled “一般語の「コンポーネント」との区別”

フレームワークの世界では、画面上のあらゆる要素をコンポーネントと呼びます。この手法の IslandPart も、実装上はフレームワークのコンポーネントとして書かれます。

この文書で Component と書いたときは、常に第4の単位を指します。一般語として言うときは「コンポーネント」とカタカナで書きます。

Part は、状態を持たず、UIの表現だけを担当する境界です。担当するのは Presentation Boundary、つまり「ここは受け取った情報を見せるだけの単位である」という切れ目です。

Text
Icon
Divider
Badge
Card

外部から受け取った情報を、描画・装飾・配置として表現します。自分では何も決めません。表示する文字列も、色も、サイズも、外から渡されます。

Part = Stateless

アニメーションの進行度も、画像の読み込み状態も、レイアウト計算のキャッシュも持たせません。持たせた時点で、その要素は Component になります。

「これは実装上の状態だから数えない」という例外を作らないのは、例外があると基準そのものが議論の対象になるからです。アニメーションの進行度は描画の都合なのか、ユーザーから見た振る舞いなのか。この議論を始めると、判定は人によって変わります。状態変数が1つでもあれば Component、と決めておけば、そこで迷いません。

Badge をフェードインさせたい。Avatar に読み込み中のプレースホルダーを出したい。そういう要件が来たときの選択肢は2つです。

  1. その要素を Component にする — 状態を自分で持たせ、分類を変える
  2. 状態を外へ出す — 表示中かどうか、読み込み済みかどうかを外から受け取り、Part のままにする

2を選ぶと、Part の集合は「渡された値をそのまま描くもの」に保たれます。同じ入力に対して常に同じ表示になるので、確認も表示を見るだけで済みます。

先に挙げた例が TextIcon のような小さいものばかりなので誤解しやすいのですが、Part は内部に多くの要素を持っていても構いません。

たとえば、受け取った時系列データを描く折れ線グラフを考えます。

LineChart(Part)
├─ 軸
├─ 目盛りのラベル
├─ 凡例
└─ 折れ線とマーカー

内部には4種類の要素があり、コード量も Text の何十倍もあります。それでも Part です。状態を1つも持っていないからです。データも配色も範囲も外から受け取り、自分では何も覚えていません。

ここで「特定の期間を選択して拡大できる」という要件が加わると、判定が変わります。選択中の期間を自分で保持することになるため、Component へ移ります。要素の見た目も大きさもコード量も変わっていませんが、状態を1つ持ったので分類が変わります。

大きさは判定に関係しません。数えるのは状態の数だけです。

判定の基準は、単位によって違います。 Island かどうかは機能として独立しているかで、ComponentPart かは状態を持つかで決まります。1つの基準で5つすべてを分けるわけではありません。

単位 決めること 持つ状態の例 持たないもの
Scaffold 画面をどう成立させるか サイドバーの開閉、表示モード 特定の Island 同士だけの共有状態
Bridge 機能同士がどう協調するか 選択中のID、フィルタ条件 レイアウト、視覚表現
Island この機能が何を管理するか スクロール位置、検索文字列 他の機能の内部状態
Component どう操作できるか 開閉状態、入力中かどうか 機能としてのデータ
Part どう見えるか なし あらゆる状態

大きさで決めようとする。 大きい・小さい、単純・複雑は、どの単位でも判定に使いません。Island かどうかは機能の独立性で、Component かどうかは状態の有無で決まります。

アニメーションのために状態を持たせて、Part のままにする。 状態を持たせた時点で Component です。Part に留めたいなら、その状態を外へ出します。判定に例外を作ると、基準そのものが議論の対象になります。

Bridge に見た目を持たせる。 余白や枠線を持たせると描画構造に現れてしまい、レスポンシブで配置が変わったときに Bridge の再構成が必要になります。Bridge が持つのは関係だけです。

すべての共有状態を Scaffold へ集める。 画面全体で意味を持つ状態だけが Scaffold のものです。2つの Island の都合でしかない状態は Bridge へ置きます。

Island を1つも作らない。 Scaffold の直下にいきなり Component が並んでいる場合、機能の境界が切られていない可能性があります。状態が Scaffold へ集まり、更新境界も画面全体になります。