第2章 状態所有
状態所有とは、ある状態をどの単位が保持し、更新の責任を持つかということです。状態を「どう管理するか」(仕組み)ではなく、「誰が持つか」(所在)を決める話です。
2択では足りない
Section titled “2択では足りない”状態の置き場所は、ローカルステートとグローバルステートの2択で語られることが多くあります。ひとつのコンポーネントの中に置くか、アプリ全体から見える場所に置くか。
この2択しかないと、共有が必要になった時点で行き先はグローバルしかありません。「2箇所で使うから上へ」という判断がすべての状態を最上位へ集めてしまうのは、途中に置き場所がないからです。
UI Boundary Architecture は、その間に3段階を置きます。
Component ── Island ── Bridge ── Scaffold ── Application(ローカル) └──── この手法が足す3段階 ────┘ (グローバル)これで「共有するからグローバルへ」ではなく、「どこまでの範囲で共有するのか」を問えるようになります。
状態は、それを必要とするUIのもっとも狭い適切な境界が所有します。
所有者の候補
Section titled “所有者の候補”Application ↓Scaffold ↓Bridge ↓Island ↓Component上から順に、影響が及ぶ範囲が広い所有者です。下へ行くほど狭くなります。
| 所有者 | 所有する状態 | 例 |
|---|---|---|
| Application | 画面をまたいで意味を持つ状態 | ログイン中のユーザー、テーマ設定、サーバーから取得したデータのキャッシュ、複数の画面が読む選択状態・表示モード |
Scaffold |
その画面にいる間だけ意味を持ち、画面全体に影響する状態 | サイドバーの開閉、選択中のタブ |
Bridge |
複数の Island が共有する状態 |
選択中のID、複数機能に効くフィルタ条件 |
Island |
その機能の内部で完結する状態 | 一覧のスクロール位置、検索文字列 |
Component |
ひとつの操作単位で完結する状態 | ドロップダウンの開閉、入力中かどうか |
Application は5つの単位には含まれません。 画面を離れても保持され続ける状態の置き場所であり、UIの分割とは別の層です。ログイン中のユーザー情報は、どの画面へ移動しても同じ値である必要があるため、Scaffold より上に置きます。
置き場所としては、アプリケーション全体で1つだけ存在する状態管理ライブラリのストア、ルートに置いた Provider、サーバーから取得したデータのキャッシュライブラリなどが該当します。共通しているのは、画面の切り替えによって作り直されないことです。
Application 層も、まとまりごとに分ける
Section titled “Application 層も、まとまりごとに分ける”Bridge を「共有する状態のまとまりごとに作る」のと同じ規則が、Application 層にも要ります。
ここへ上げた状態を1つのストアへ集めると、Scaffold で起きた問題が1段上で再現します。誰が読んでいるのか追えなくなり、無関係な状態の更新まで同じ範囲へ届きます。置き場所がグローバルになっただけで、God Page と同じ形です。
読む顔ぶれごとに分けます。 全画面が読むテーマ設定と、2つの画面だけが読む選択状態は、同じ入れ物に入れません。
スコープを持つ仕組み(Riverpod の ProviderScope / autoDispose など)を使うなら、スコープを読む範囲と一致させます。これは Bridge の実装と同じ手順で、違うのは範囲が画面をまたぐことだけです。単一ストアしか持たない仕組み(Redux など)では範囲を構造として表現できないため、ストアを分けるか、少なくとも「この状態を読むのはどの画面か」をコード上に明記します。
所有者は、次の順に問うことで決まります。
- ひとつの
Componentだけで必要か。 →Componentが所有する Island全体で必要か。 →Islandが所有する- 複数の
Islandで共有するか。 →Bridgeが所有する - 画面全体で意味を持つか。 →
Scaffoldが所有する - 複数の画面が読むか、画面を離れても保持する必要があるか。 → Application が所有する
上から順に確認し、最初に当てはまったところで止めます。
ただし「もっとも狭い」には「適切な」が付いています。狭ければ狭いほどよい、ではありません。 その条件は次の節で扱います。
この考え方により、状態の配置をUI構造と同じ語彙で判断できます。「この値をどこに置くべきか」という問いが、「この値はどの境界の内側で意味を持つか」という問いに置き換わります。
「もっとも狭い」ではなく「もっとも狭い適切な」
Section titled “「もっとも狭い」ではなく「もっとも狭い適切な」”原則は「もっとも狭い」ですが、そこに「適切な」が付いています。狭ければ狭いほどよい、ではありません。
狭すぎる場合。 状態を Component に置いたあとで、隣の Component も同じ値を必要とすることが分かると、状態を上へ移す作業が発生します。この移動は、値の受け渡しだけでなく、更新処理の置き場所も変えます。近い将来に共有されることが分かっているなら、最初から Island に置いて構いません。
広すぎる場合。 すべての状態を Scaffold に置けば、移動は発生しません。しかし更新境界が画面全体になり、どの値が誰に使われているのかも追えなくなります。
判断が割れたときは、その状態を変更するコードがどこにあるかを見ます。変更するコードが Island の内側にしかないなら、状態も Island が持つのが自然です。
SNSのタイムライン画面の状態を分類すると、次のようになります。
Application└─ 認証済みユーザー、テーマ設定、取得済みの投稿
TimelineScaffold└─ IsSidebarCollapsed(左ナビゲーションの折りたたみ)
PostSelectionBridge└─ SelectedPostId(開いている投稿)
TimelineIsland├─ FeedTab(おすすめ / フォロー中)├─ ScrollPosition(スクロール位置)└─ UnreadCount(未読の新着件数)
ThreadIsland└─ AreRepliesExpanded(返信を全件表示しているか)
ComposerIsland├─ DraftText(下書きの本文)└─ AttachedImages(添付画像)
PostCard(Component)└─ IsMenuOpen(その投稿のメニューが開いているか)SelectedPostId が Bridge にあるのは、タイムラインとスレッドの両方が必要とするからです。
FeedTab が TimelineIsland にあるのは、タブの切り替えがタイムラインの内部で完結するからです。スレッドはどのタブが選ばれているかを知りません。
DraftText が ComposerIsland にあるのは、下書きが投稿欄だけの問題だからです。
IsMenuOpen が PostCard にあるのは、メニューの開閉がその1件の投稿だけの問題だからです。画面に100件の PostCard が並んでいても、状態は各自が持ちます。
所有者を移すとき
Section titled “所有者を移すとき”分類は途中で変わります。Component が持っていた状態を Island が必要とし始めることも、Island の状態を隣の Island が参照し始めることもあります。
所有者を移すときに直すのは、次の3つです。
- 状態の宣言位置 — 新しい所有者の内側へ移す
- 更新処理の置き場所 — 状態を変える関数も一緒に移す。宣言だけ移して更新を元の場所に残すと、値の所有者と変更の責任者が分かれ、どちらを見ればよいか分からなくなる
- 参照側の受け取り方 — 自分の状態として読んでいた側は、外から受け取る形へ変わる
Island から Bridge へ移す場合を例にすると、次の順で進めます。
1. Bridge を作り、対象の Island を包む2. 状態と更新関数を Island から Bridge へ移す3. 元の Island を、Bridge から値を受け取る形へ変える4. もう一方の Island も、同じ Bridge から受け取る形にするSelectedPostId を TimelineIsland から PostSelectionBridge へ移す場合、構造はこう変わります。
移す前TimelineScaffold├─ TimelineIsland SelectedPostId を宣言し、更新もここで行う└─ ThreadIsland props で受け取る
移した後TimelineScaffold└─ PostSelectionBridge SelectedPostId を宣言し、更新もここで行う ├─ TimelineIsland Bridge から読む └─ ThreadIsland Bridge から読む2と3を分けているのは、先に移してから参照側を直すほうが、途中の状態でも動かせるためです。一度に書き換えると、どこまで直したか分からなくなります。
ページ全体をほぐす流れの中で、この作業がどこに来るのかは 第6章 適用のしかた の「一度に全部やらない」を参照してください。
移したあとは、元の所有者がその状態を読んでいないことを確認します。読んだままだと、同じ値が2箇所に存在することになります。
アンチパターン
Section titled “アンチパターン”すべてをグローバルストアへ置く。 状態管理ライブラリを導入すると、どこからでも読み書きできる場所ができます。そこへすべて置くと、状態の所在は「グローバル」の一言になり、どの機能が壊れるかを予測できなくなります。グローバルストアは Application 層の道具として使い、画面内の状態まで持ち込みません。
画面をまたぐ状態を、ひとつのストアへまとめる。 画面内の状態をグローバルへ持ち込まなくても、Application 層に置いた状態同士を1つにまとめれば同じことが起きます。テーマ設定と、2つの画面だけが読む選択状態は、読む顔ぶれが違います。まとまりごとに分けます。
共有されるたびに上へ上げる。
2つのコンポーネントが同じ値を使うたびに、共通の親へ状態を上げていくと、最終的に画面の最上位へ集まります。上げる前に、その2つが同じ Island の内側にいるのか、別の Island にまたがっているのかを確認します。またがっているなら、上げる先は共通の親ではなく Bridge です。
サーバーから取得したデータを Island が持つ。
タイムラインが取得した投稿の配列を TimelineIsland の内部状態にすると、スレッドが同じ投稿を表示するときに再取得するか、Island 間で受け渡すことになります。取得したデータは Application 層のキャッシュに置き、Island はそこから読むだけにします。Island が持つのは「その Island がどう表示しているか」であって、「データそのもの」ではありません。