コンテンツにスキップ

第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(その投稿のメニューが開いているか)

SelectedPostIdBridge にあるのは、タイムラインとスレッドの両方が必要とするからです。 FeedTabTimelineIsland にあるのは、タブの切り替えがタイムラインの内部で完結するからです。スレッドはどのタブが選ばれているかを知りません。 DraftTextComposerIsland にあるのは、下書きが投稿欄だけの問題だからです。 IsMenuOpenPostCard にあるのは、メニューの開閉がその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 から受け取る形にする

SelectedPostIdTimelineIsland から 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 がどう表示しているか」であって、「データそのもの」ではありません。