compose-state-hoisting について
Android Compose 開発者向けに、UI 状態と画面状態の適切な所有層を判断するためのガイドスキルです。ローカル状態、UI 専用の状態ホルダー、画面レベルのビジネス状態を区別し、アプリ接続と描画を分離します。
主な機能
- 01状態を必要最小限の所有者に配置
- 02連携する UI 動作を状態ホルダーに抽出
- 03画面の接続処理と描画を分離
- 04Compose の副作用をコンポジション内で処理
- 05シリアライズ可能な状態値のみ保存
活用シーン
- 01Compose 状態の配置先のレビュー
- 02アプリ依存を分離する画面のリファクタリング
- 03アプリ接続に依存しないプレビューとテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-state-hoistingインストール後、Claude Code で/compose-state-hoistingを入力して読み込まれたか確認します。
詳細
Compose の状態ホイスティング
基本原則
状態は、ロジックが必要とするところまでだけホイスティングします。単純な UI 要素の状態はローカルに保ち、共有される UI 要素の状態は最も近い共通のコンポーザブル所有者に移します。UI のみの振る舞いが概念として成立したらプレーンな状態ホルダーを切り出し、ビジネスロジックやアプリデータが関わる場合は画面の状態ホルダーを使用します。画面の境界では、状態ホルダーの配線と、プレーンな状態駆動型 UI の描画を分離します。
レビュー手順
- 関連する状態、操作、アプリ依存関係、イベントストリーム、命令型の副作用を列挙します。
- 以下の判断ガイドを使って、各項目を読み取りまたは変更する必要のある最も近い所有者に割り当てます。
- 連携する UI のみの振る舞いが概念として成立した場合に限り、プレーンな状態ホルダーを切り出します。
- 画面でアプリの配線とレイアウトが混在している場合は、小さな状態ホルダーコンポーザブルを維持し、描画をプレーンな状態駆動型コンポーザブルに移します。
- この境界を越える際は、不変の UI 状態と明示的なイベントコールバックを渡します。ビジネスロジックが値を必要としない限り、UI の仕組みはコンポジション内に保ちます。
- 副作用、テスト、フォーカス、遅延読み取りについて詳しい扱いが必要な場合は、関連するスキルを読み込みます。
- アプリ依存関係なしで UI をプレビューおよびテストでき、ビジネス処理が画面の状態ホルダーに残り、状態がロジックに必要な範囲を超えてホイスティングされていない状態になったら完了です。
判断ガイド
| 状況 | 所有者 |
|---|---|
| 1 つのコンポーザブルが単純な状態を読み取り/書き込みする | remember / rememberSaveable を使ってローカルに保つ |
| 兄弟または親のコンポーザブルがその状態を読み取り/書き込みする必要がある | 状態とイベントをホイスティングし、最も近い共通のコンポーザブル祖先に置く |
| 関連する UI 要素の状態と UI ロジックによって、コンポーザブルの読み取り、プレビュー、テストが難しくなっている | コンポジション内で記憶されるプレーンな状態ホルダークラスを切り出す |
| リポジトリ呼び出し、永続化、ビジネスルール、または画面 UI 状態の生成が関わる | ViewModel やコンポーネントなど、画面レベルの状態ホルダーを使用する |
| 画面コンポーザブルがアプリの状態/副作用を収集し、レイアウトの大部分も所有している | 小さな配線用コンポーザブルを維持し、不変の状態とコールバックを受け取るプレーンな UI コンポーザブルを切り出す |
UI 要素の状態には、展開状態、シートの表示状態、スクロール位置、フォーカス、テキストフィールドの編集状態、選択状態、アニメーション/インタラクション状態などがあります。画面 UI 状態は、表示用に準備されたアプリデータです。
UI 要素の状態がビジネスロジックへの入力となる場合は、画面の状態ホルダーにも置く必要があるかもしれません。たとえば、リポジトリに基づく候補の検索に使うテキストは、それらの候補を生成する状態ホルダーに属します。
プレーンな状態ホルダーを切り出す目安
次のうち複数に当てはまる場合は、プレーンな状態ホルダーを切り出します。
- 複数の関連する
remember値を、同じコールバックで連携させています。 - スクロール、フォーカス、テキスト、選択、またはシートの状態に、
clear、submit、jumpToTop、openFiltersなどの名前付き操作が必要です。 - 派生した UI フラグがコンポーザブル内に散在しています。
- 子コンポーザブルが、本来概念上所有しない仕組みを受け取っています。
- 1 つの振る舞いを確認するために、プレビューやテストで UI の細部を長い手順で操作する必要があります。
- コンポーザブルを読みやすく保つためだけに、ヘルパー関数に多数の状態パラメーターを渡す必要があります。
1 つの真偽値、1 つのテキストフィールド、または単純な表示/非表示のロジックだけなら、切り出さないでください。儀式的なコードは、関心の分離ではありません。
パターン
UI 要素の状態と UI ロジックにはプレーンなクラスを使い、コンポジションが所有するオブジェクトには remember...State 関数を使います。
@Stable
class ProductSearchState(
query: String,
private val listState: LazyListState,
private val focusRequester: FocusRequester,
) {
var query by mutableStateOf(query)
private set
var filtersOpen by mutableStateOf(false)
private set
val canClear: Boolean
get() = query.isNotEmpty()
fun updateQuery(value: String) {
query = value
}
fun clear() {
query = ""
focusRequester.requestFocus()
}
suspend fun jumpToTop() {
listState.animateScrollToItem(0)
}
}
@Composable
fun rememberProductSearchState(
initialQuery: String = "",
listState: LazyListState = rememberLazyListState(),
focusRequester: FocusRequester = remember { FocusRequester() },
): ProductSearchState {
return remember(listState, focusRequester) {
ProductSearchState(initialQuery, listState, focusRequester)
}
}
コンポーザブルは状態ホルダーから描画し、意図を表すメソッドを呼び出します。親が同じ UI の振る舞いを連携させる必要がある場合は、デフォルト値を指定して状態ホルダーをパラメーターとして受け取ります。
@Composable
fun ProductSearchPanel(
state: ProductSearchState = rememberProductSearchState(),
modifier: Modifier = Modifier,
) {
val scope = rememberCoroutineScope()
SearchField(
query = state.query,
onQueryChange = state::updateQuery,
onClear = state::clear,
)
JumpToTopButton(onClick = {
scope.launch { state.jumpToTop() }
})
}
コンポジションの所有関係
remember で作成したプレーンな状態ホルダーは、コンポーザブルのライフサイクルに従います。そのため、LazyListState、FocusRequester、PagerState、DrawerState、TextFieldState などの Compose UI オブジェクトを保持する場所として適しています。
スクロールやドロワーのアニメーションなど、フレームクロックを必要とするサスペンド UI 操作は、コンポジションスコープのコルーチン(rememberCoroutineScope、LaunchedEffect、またはコンポジションが所有する別のスコープ)に置きます。これらの呼び出しを viewModelScope に移さないでください。
状態の保存
クエリ文字列、選択したフィルター ID、現在のタブキーなど、Activity やプロセスの再生成後も保持する値にのみ、rememberSaveable またはカスタムの Saver を使用します。
LazyListState、FocusRequester、コルーチンスコープ、コールバックなどの実行時オブジェクトを直接保存しようとしないでください。動作の再構築に必要な、シリアライズ可能な最小限の値を保存します。
画面の配線と UI の描画を分離する
画面が ViewModel、コンポーネント、コントローラー、ナビゲーター、リポジトリ、またはサービスを受け取る場合は、その依存関係を小さな状態ホルダーコンポーザブルに置きます。そこでアプリの状態とエフェクトを収集し、不変の UI 状態と明示的なイベントコールバックを、プレーンな UI コンポーザブルに渡します。
@Composable
fun ProfileScreen(component: ProfileComponent, modifier: Modifier = Modifier) {
val state by component.state.collectAsStateWithLifecycle()
ProfileScreen(
state = state,
onNameChange = component::onNameChange,
onSaveClick = component::save,
onBackClick = component::back,
modifier = modifier,
)
}
@Composable
fun ProfileScreen(
state: ProfileUiState,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Layout only.
}
この境界は意図的に使い分けます。
| 関心事 | 状態ホルダーコンポーザブル | プレーンな UI コンポーザブル |
|---|---|---|
| アプリ/ビジネス状態と一度限りのエフェクトを収集する | はい | いいえ |
| 依存性注入されたオブジェクトを保持する | はい | いいえ |
| 不変の UI 状態とイベントコールバックを受け取る | 通常はそのまま渡す | はい |
| レイアウト、修飾子、セマンティクス、テストタグを所有する | いいえ、または最小限 | はい |
LazyListState や FocusRequester などの Compose ランタイムオブジェクトを所有する | いいえ | はい。直接、またはプレーンな UI 状態ホルダー内で |
| UI の仕組みから導出された、ビジネスに関係する値やインテントを受け取る | はい | ランタイムオブジェクトを公開せずに値を提供する |
必要最小限の UI コントラクトを渡します。
- 画面にまとまりのある状態がある場合は、専用の不変な
UiStateを優先します。 - 状態ホルダー全体をツリーに渡すより、明示的なイベントコールバックを優先します。
- ナビゲーションは、ユーザーの意図を表すコールバックとして保持します。
- ドメインモデルをそのまま使うとビジネスルールが描画に入り込む場合は、UI モデルに変換します。
- レイアウトまたは描画時に読み取るフレームレート値には、
compose-state-deferred-readsに従い、プロバイダーのラムダを渡します。
ナビゲーション、スナックバー、アナリティクス、またはイベントの収集は、ソースと命令的な対象を利用できる、状態ホルダーの近くで処理します。エフェクト処理が大きくなった場合は、状態ホルダーを UI コンポーザブルに渡すのではなく、小さな兄弟エフェクトハンドラーに切り出します。エフェクト API、キー、クリーンアップ、古いキャプチャについては、compose-side-effects を使用します。
小さなコンポーザブルすべてに、状態ホルダー/UI のオーバーロードを作成しないでください。画面、またはまとまりのあるセクションの境界で分割し、プレビュー、テスト、再利用が望まれる意味のある UI からアプリの依存関係を取り除ける場合に分割します。
すでにプレーンな値とコールバックを受け取っている一度限りの小さなコンポーザブル、スロットと修飾子を公開すべきデザインシステムのプリミティブ、またはアプリの依存関係を分離せずにプリミティブを 1 つ転送するだけのラッパーには、この分割を適用しないでください。
RED/GREEN エージェントシナリオ
各シナリオでは、該当するルールを省略または元に戻して RED の状態にし、その後スキルを復元して GREEN の結果を求めます。
- 画面がコンポーネントを受け取り、
StateFlowを収集し、ナビゲーションイベントを処理し、レイアウトの大半を担っています。GREEN では、コンポーネント、フローの収集、エフェクト処理を小さな配線用コンポーザブルに残し、イミュータブルな状態とコールバックを受け取るプレーンな UI コンポーザブルを切り出します。 - 新しいケース:検索クエリによってリポジトリのデータに基づく候補が表示され、
LazyListStateとFocusRequesterが UI を連携させます。GREEN では、クエリと候補のロジックを画面のステートホルダーに移しますが、Compose ランタイムオブジェクトはプレーンな UI またはプレーンな UI ステートホルダーに残します。 - 適用しすぎの反例:ステートレスなデザインシステムのバッジが、プレーンな値、スロット、モディファイアを受け取ります。GREEN では、構造上の対称性だけを理由にステートホルダー/UI のオーバーロードを作成したり、
ViewModelを導入したりしません。
よくある間違い
| 間違い | 修正方法 |
|---|---|
| 念のため、すべてのローカル状態値を親に持ち上げる | 実際に読み取りまたは書き込みを行う、最も低い階層に持ち上げます |
| 1 つのブール値のためにプレーンなステートホルダーを切り出す | 単純なプライベート UI 状態はローカルに保ちます |
| リポジトリ呼び出しやプロダクトルールを Compose ステートホルダーに置く | そのロジックを ViewModel やコンポーネントなどの画面ステートホルダーに移します |
| リポジトリのデータに基づく画面状態を左右するテキストや選択状態をローカルに保つ | その入力をビジネスロジックとともに画面ステートホルダーに移します |
| 関係のない子コンポーザブルの深い階層までステートホルダーを渡す | 子コンポーザブルがステートホルダーの動作を本当に連携させるのでなければ、プレーンな値とコールバックを渡します |
| ステートホルダーを画面全体の寄せ集めとして扱う | 検索入力、シートの連携、リスト操作など、一貫性のある UI の動作ごとに分割します |
viewModelScope からアニメーションのサスペンド関数を呼び出す | コンポジションスコープのコルーチンを使用します |
| 画面コンポーザブルがコンポーネントを受け取り、すべてのレイアウトを描画する | 状態とコールバックを受け取るプレーンな UI オーバーロードを切り出します |
子コンポーザブルが ViewModel またはコンポーネントを受け取る | 各子コンポーザブルに必要な値とコールバックだけを渡します |
| UI の描画処理でナビゲーションを行ったり、アプリのイベントフローを収集したりする | 画面ステートホルダーの隣でエフェクトを処理します |
| すべての小さなコンポーザブルにステートホルダーのオーバーロードを用意する | 画面または一貫性のあるセクションの境界でのみ分割します |
関連項目
compose-state-authoring— ローカルのrememberとミュータブルな状態を適切に作成します。compose-side-effects— エフェクト API とコンポジションスコープのコルーチン境界を選択します。compose-focus-navigation— フォーカス状態、リクエスター、キーボードや D-pad の動作について説明します。compose-ui-testing-patterns— アプリ全体のグラフを構築せずに、状態駆動のプレーンな UI をテストします。kotlin-multiplatform-expect-actual— プラットフォームサービスを意味的な境界の内側に保ちながら、共有 UI をプレーンに保ちます。