compose-state-holder-ui-split について
状態ホルダーと UI 描画を分離する Compose スキルです。UI を不変状態とコールバック中心にし、画面のプレビューやテスト、再利用をしやすくします。
主な機能
- 01状態の接続と UI レイアウトの分離
- 02状態ホルダーでのビジネス状態収集
- 03状態とコールバックによる UI 契約の受け渡し
- 04状態ホルダー付近での副作用処理
- 05UI ローカル状態とビジネス状態の区別
活用シーン
- 01ViewModel 依存画面のレイアウト分離
- 02アプリ全体の依存構築を省いた UI テスト
- 03UI 内のナビゲーションを明示的なコールバックに置換
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-state-holder-ui-splitインストール後、Claude Code で/compose-state-holder-ui-splitを入力して読み込まれたか確認します。
詳細
Compose:状態ホルダーと UI の分離
基本原則
状態ホルダーの配線と UI の描画を分離します。状態ホルダーのコンポーザブルは、ViewModel、コンポーネント、フロー、ナビゲーション、副作用を扱います。UI コンポーザブルは、変更不可のプレーンな UI 状態とコールバックを受け取り、レイアウトを記述します。
これにより、画面のプレビューやテストがしやすくなり、Android、Desktop、TV、KMP/CMP の各ターゲットで再利用しやすくなります。
このスキルを使う場面
Compose の画面で次のいずれかに当てはまる場合に使います。
- 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,
)
}
次に、状態ホルダーを一切知らないプレーンなコンポーザブルに UI を配置します。
@Composable
fun ProfileScreen(
state: ProfileUiState,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
ProfileContent(
name = state.name,
isSaving = state.isSaving,
canSave = state.canSave,
onNameChange = onNameChange,
onSaveClick = onSaveClick,
onBackClick = onBackClick,
modifier = modifier,
)
}
プライベートなコンテンツ関数でレイアウトを分割できます。
@Composable
private fun ProfileContent(
name: String,
isSaving: Boolean,
canSave: Boolean,
onNameChange: (String) -> Unit,
onSaveClick: () -> Unit,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
// Layout only.
}
基本的な指針
| 項目 | 状態ホルダーコンポーザブル | UI コンポーザブル |
|---|---|---|
| ViewModel/コンポーネントの状態を収集する | はい | いいえ |
| 一度限りのエフェクトを収集する | はい。または小さな別のエフェクトハンドラー | 通常はいいえ |
| 依存性注入されたオブジェクトを保持する | はい | いいえ |
| 変更不可の UI 状態を受け取る | 通常はそのまま渡す | はい |
| ユーザーイベント用のラムダを受け取る | 配線する | 呼び出す |
| レイアウト、モディファイア、セマンティクス、テストタグを所有する | いいえ/最小限 | はい |
| スクロール、フォーカス、テキスト入力、アニメーション、インタラクションなどの UI ローカル状態を所有する | 場合によっては初期化する | はい |
| プレビュー/スクリーンショットへの対応 | 必ずしもそうではない | はい |
「UI コンポーザブルで収集しない」というルールは、アプリやビジネスの状態と副作用のストリームを対象としています。プレーンな UI コンポーザブルは、rememberScrollState、rememberLazyListState、FocusRequester、フォーカス状態、アニメーション状態、TextFieldState、MutableInteractionSource.collectIsPressedAsState() など、描画されるウィジェットに属する UI ローカルなフレームワーク状態を引き続き所有できます。
その UI ローカル状態が、複数の関連するフィールドや操作を含む協調的な動作に発展した場合は、compose-state-hoisting を使って、コンポジション内で記憶されるプレーンな状態ホルダークラスにするべきか判断してください。
渡すもの
必要最小限の UI コントラクトを渡します。
- 画面に実際の状態がある場合は、無関係なプリミティブ値を多数渡すのではなく、専用の
UiState/Stateオブジェクトを優先します。 - コンポーネント全体を渡すのではなく、明示的なラムダ(
onRetryClick、onItemSelected)を優先します。 - ビジネスルールを UI に持ち込むことになる場合は、UI コンポーザブルでドメインモデルを使わないでください。UI に異なる形のデータが必要な場合は、UI モデルにマッピングします。
- ナビゲーションはコールバックとして扱います。UI コンポーザブルは「ユーザーが戻るボタンをクリックした」と伝え、「ルート X に移動する」とは伝えません。
- 変更時にツリー全体の再コンポジションを引き起こすべきではないフレームレート値や UI ローカル値には、
compose-state-deferred-readsに従ってプロバイダーラムダと遅延読み取りを優先します。
副作用
compose-side-effects では、エフェクト API(LaunchedEffect、DisposableEffect、SideEffect)、キー、クリーンアップ、rememberUpdatedState を扱います。
エフェクトのソースと命令型の処理先の両方を利用できる、状態ホルダーの近くでエフェクトを処理します。
@Composable
fun ProfileScreen(component: ProfileComponent, snackbarHostState: SnackbarHostState) {
val state by component.state.collectAsStateWithLifecycle()
LaunchedEffect(component) {
component.effects.collect { effect ->
when (effect) {
ProfileEffect.Saved -> snackbarHostState.showSnackbar("Saved")
}
}
}
ProfileScreen(state = state, onSaveClick = component::save)
}
エフェクト処理が大きくなった場合は、コンポーネントを UI コンポーザブルに渡すのではなく、ProfileEffects(component, snackbarHostState) を切り出します。
よくある間違い
| 間違い | 問題点 | 修正方法 |
|---|---|---|
fun Screen(viewModel: MyViewModel) にレイアウト全体を含める | Android のライフサイクルや DI なしではプレビューやテストが困難です | state とコールバックを受け取る、プレーンな UI オーバーロードを追加します |
子コンポーザブルが component を受け取る | 依存関係がツリー全体に漏れます | 子が必要とする状態とコールバックだけを渡します |
| UI コンポーザブルがナビゲーションを開始する | UI がアプリのルーティングと密結合になります | onBackClick、onItemClick などを公開します |
| UI コンポーザブルがアプリ/ビジネスフローを収集する | 収集のライフサイクルがレイアウト内に隠れてしまいます | 状態ホルダーの近くで収集し、値を下位に渡します |
| 理由もなく UI ローカルの状態を状態ホルダーに持ち上げる | 状態ホルダーがレイアウトの仕組みまで所有するようになります | スクロール、フォーカス、アニメーション、テキストフィールドの操作状態が UI の動作だけに関するものであれば、UI コンポーザブルに保持します |
| 小さなコンポーザブルのすべてに状態ホルダー用のオーバーロードを追加する | 儀式的な記述が増えすぎます | すべての Row ではなく、画面やセクションの境界で分割します |
適用しない場合
- すでにプレーンな値とコールバックを受け取っている、小規模で単発のコンポーザブル。
Button、Card、ListItemなどのデザインシステムのプリミティブ。これらは状態ホルダーではなく、スロットと修飾子を公開するべきです。- 状態ホルダーを使うコンポーザブルがプリミティブを 1 つ渡すだけで、分離の効果がない場合。
関連項目
compose-ui-testing-patterns— アプリ全体のグラフを使わずに、プレーンな状態駆動型 UI コンポーザブルをテストする方法。compose-state-hoisting— プレーンな状態ホルダークラスを含め、UI 要素の状態と UI ロジックをどこに置くかを判断する方法。kotlin-multiplatform-expect-actual— 共有 UI がプラットフォーム固有のリーフと連携する場合の、プラットフォームサービス、ネイティブビュー、expect/interface の境界。