コンテンツにスキップ

第2章 状態所有

状態所有とは、ある状態をどの単位が保持し、更新の責任を持つかということです。状態を「どう管理するか」(仕組み)ではなく、「誰が持つか」(所在)を決める話です。

状態の置き場所は、ローカルステートとグローバルステートの2択で語られることが多くあります。ひとつのコンポーネントの中に置くか、アプリ全体から見える場所に置くか。

この2択しかないと、共有が必要になった時点で行き先はグローバルしかありません。「2箇所で使うから上へ」という判断がすべての状態を最上位へ集めてしまうのは、途中に置き場所がないからです。

UI Boundary Architecture は、その間に3段階を置きます。

Component ── Island ── Bridge ── Scaffold ── Application
(ローカル) └──── この手法が足す3段階 ────┘ (グローバル)

これで「共有するからグローバルへ」ではなく、「どこまでの範囲で共有するのか」を問えるようになります。

状態は、それを必要とするUIのもっとも狭い適切な境界が所有します。

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 など)では範囲を構造として表現できないため、ストアを分けるか、少なくとも「この状態を読むのはどの画面か」をコード上に明記します。

所有者は、次の順に問うことで決まります。

  1. ひとつの Component だけで必要か。 → Component が所有する
  2. Island 全体で必要か。 → Island が所有する
  3. 複数の Island で共有するか。 → Bridge が所有する
  4. 画面全体で意味を持つか。 → Scaffold が所有する
  5. 複数の画面が読むか、画面を離れても保持する必要があるか。 → 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 が並んでいても、状態は各自が持ちます。

分類は途中で変わります。Component が持っていた状態を Island が必要とし始めることも、Island の状態を隣の Island が参照し始めることもあります。

所有者を移すときに直すのは、次の3つです。

  1. 状態の宣言位置 — 新しい所有者の内側へ移す
  2. 更新処理の置き場所 — 状態を変える関数も一緒に移す。宣言だけ移して更新を元の場所に残すと、値の所有者と変更の責任者が分かれ、どちらを見ればよいか分からなくなる
  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箇所に存在することになります。

すべてをグローバルストアへ置く。 状態管理ライブラリを導入すると、どこからでも読み書きできる場所ができます。そこへすべて置くと、状態の所在は「グローバル」の一言になり、どの機能が壊れるかを予測できなくなります。グローバルストアは Application 層の道具として使い、画面内の状態まで持ち込みません。

画面をまたぐ状態を、ひとつのストアへまとめる。 画面内の状態をグローバルへ持ち込まなくても、Application 層に置いた状態同士を1つにまとめれば同じことが起きます。テーマ設定と、2つの画面だけが読む選択状態は、読む顔ぶれが違います。まとまりごとに分けます。

共有されるたびに上へ上げる。 2つのコンポーネントが同じ値を使うたびに、共通の親へ状態を上げていくと、最終的に画面の最上位へ集まります。上げる前に、その2つが同じ Island の内側にいるのか、別の Island にまたがっているのかを確認します。またがっているなら、上げる先は共通の親ではなく Bridge です。

サーバーから取得したデータを Island が持つ。 タイムラインが取得した投稿の配列を TimelineIsland の内部状態にすると、スレッドが同じ投稿を表示するときに再取得するか、Island 間で受け渡すことになります。取得したデータは Application 層のキャッシュに置き、Island はそこから読むだけにします。Island が持つのは「その Island がどう表示しているか」であって、「データそのもの」ではありません。