compose-state-authoring について
Compose のローカル UI 状態管理と `@ReadOnlyComposable` の適切な使い方を扱うガイドです。再コンポーズでリセットされる状態や更新を通知しない状態を防ぎ、合成状態のみを読み取ると宣言できる条件を説明します。
主な機能
- 01`remember` によるローカル状態の保持
- 02スナップショット状態による更新通知
- 03監視可能な状態コレクションの選択
- 04コンポジション中の状態書き戻しの回避
- 05契約に沿った `@ReadOnlyComposable` の使用
活用シーン
- 01再コンポーズでリセットされるローカル変数の修正
- 02コレクション変更の監視可能化
- 03入力からの読み取り専用スナップショット生成
- 04読み取り専用コンポーザブルアクセサーのレビュー
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ chrisbanes/ skills --skill compose-state-authoringインストール後、Claude Code で/compose-state-authoringを入力して読み込まれたか確認します。
詳細
Compose の状態の記述
すべての remember { … } がこのスキルの対象になるわけではありません。このスキルでは、ローカル UI 状態(remember { mutableStateOf(…) }、mutableStateListOf/mutableStateMapOf)と @ReadOnlyComposable を扱います。その他の記憶される API については、それぞれのスキルを参照してください。
rememberCoroutineScope/rememberUpdatedState→compose-side-effects- フレームレートでの読み取りに使用する
rememberLazyListState/rememberScrollState→compose-state-deferred-reads - フォーカスナビゲーション、フォーカス状態、
FocusRequesterの所有権、動作 →compose-focus-navigation
基本原則
@Composable は、入力が変わるたびにランタイムが再実行する関数です。ローカル状態を正しく記述するには、次の 2 つの問いに答える必要があります。
- 可変のローカル状態 —
varは再コンポジションをまたいで保持され、かつ再コンポジションを発生させますか?そうでなければ、再コンポジションのたびにひそかにリセットされ、書き込みも認識されません。 - これはどの種類のコンポーザブルですか? — コンポジションを変更しますか(レイアウトノードを配置する、スロットを割り当てる、
rememberを使う)、それとも読み取るだけですか?読み取るだけなら、@ReadOnlyComposableによってランタイムが処理を省略できます。
どちらかを誤ると、状態が消える、最適化が適用されないといった、気づきにくい症状が現れます。
このスキルを使う場面
Compose コードを記述またはレビューしていて、次のいずれかに該当する場合です。
@Composable funまたは任意のコンポーザブルラムダ(Column { var x = … })の中にvar x = …がある- 本体でレイアウトを一切行わない
@Composable fun(または@Composable get()プロパティアクセサー)がある Text、Box、Column、rememberなどを呼び出す関数に@ReadOnlyComposableが付いている- 表示される状態が、画面の回転、テーマの変更、または再コンポジションの際に不可解にリセットされるコンポーザブルがある
1. コンポーザブル内の var は State に基づく必要がある
再コンポジションでは、コンポーザブルが先頭から再実行されます。ローカルの var は毎回再初期化されるため、前回の再コンポジションでの値は失われ、そこへの書き込みもランタイムに再コンポジションを通知しません。
// ❌ BAD — counter resets on every recomposition; clicks never update the UI
@Composable
fun Counter() {
var count = 0
Button(onClick = { count++ }) { Text("$count") }
}
// ❌ ALSO BAD — same rule applies inside composable content lambdas
@Composable
fun Wrapper() {
Row {
var count = 0 // Row's content lambda is @Composable too
// …
}
}
// ✅ GOOD — `remember` survives recomposition, `mutableStateOf` triggers it
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Button(onClick = { count++ }) { Text("$count") }
}
どちらも重要な 2 つの要素です。
remember { … }— 再コンポジションをまたいで保持します。これがなければ、値は毎回作り直されます。mutableStateOf(…)— 再コンポジションを発生させます。これがなければ、変更はランタイムに認識されません。
コレクションには、mutableStateListOf/mutableStateMapOf(これらも remember で記憶します)を使うのがおすすめです。読み取りのたびに Snapshot の読み取りを通知し、変更のたびに Snapshot の書き込みを通知します。remember { mutableStateOf(mutableListOf<X>()) } の後に list.add(x) を呼び出しても、再コンポジションは発生しません。MutableList.add は State のセッターを経由しないためです。値を置き換える必要があります(state = state + x)。
コンポジション中に Snapshot 状態へ書き戻す
書き戻しとは、先行するフェーズ(または現在のフェーズ)の無効化を引き起こすフェーズで、監視対象の状態を書き込むことです。コンポーザブル本体から mutableState* を変更すると、同じコンポジションパスに書き戻され、別のパスがスケジュールされます。この方法で派生データを再構築しないでください。
// ❌ BAD — clear + putAll on every composition
val merged = remember { mutableStateMapOf<Key, ViewState>() }
merged.clear()
merged.putAll(parent)
merged.putAll(overlay)
// ✅ GOOD — immutable snapshot remembered from inputs
val merged = remember(parent, overlay) {
if (overlay.isEmpty()) parent else parent + overlay
}
現在の入力に対して結果が読み取り専用であれば、remember(keys) { … } で十分です。行をまたぐ測定や測定フェーズの修正については、compose-state-deferred-reads を参照してください。
このルールが適用されない場合
remember { … }の生成ブロック内。 これはキーが変わったときに一度だけ実行され、再コンポジションのたびに実行されるわけではありません。その中のローカルvarは問題ありません。val builder = remember { mutableListOf<X>().apply { var n = 0; … } }のように記述できます。- コンポーザブルの外部に渡される、
@Composableではないラムダ内。onClick = { var a = 0; … }は通常の() -> Unitです。そこでのローカル変数は通常の Kotlin 変数です。 - 通常の(
@Composableではない)ヘルパー関数内。 影響を受けるのはコンポーザブルのスコープだけです。
2. @ReadOnlyComposable の契約
@ReadOnlyComposable は、コンポーザブルがコンポジションの状態を読み取るだけであることを宣言します。Text、Box、remember、レイアウトノード、位置スロットは使用しません。これにより、ランタイムは呼び出し用のグループの割り当てを省略できます。これは、高速なアクセサー形式のコンポーザブル(MaterialTheme.colorScheme、LocalDensity.current、デザインシステムのトークンアクセサー)で重要です。
この契約は双方向です。
@ReadOnlyComposableを追加するのは、関数本体が呼び出すすべてのコンポーザブルがそれ自体@ReadOnlyComposableの場合(またはコンポーザブルの呼び出しが一切ない場合。たとえば、LocalFoo.currentを読み取って値を返すだけの関数)です。- 非読み取り専用のコンポーザブルを呼び出す場合は、追加しないでください。この最適化は、コンポジションに参加しないことを前提としています。これに違反すると、呼び出し元で再コンポジションの動作が正しくなくなります。
// ✅ GOOD — only reads composition locals, no layout, no remember
@Composable
@ReadOnlyComposable
fun appSpacing(): Dp = LocalDimensions.current.spacing
// ✅ GOOD — composable property getter; same rule
val accent: Color
@Composable @ReadOnlyComposable
get() = MaterialTheme.colorScheme.tertiary
// ❌ BAD — annotated read-only but lays out a Box; contract violated
@Composable
@ReadOnlyComposable
fun Header(): Int {
Box {} // ← non-read-only composable call
return 42
}
// ❌ BAD — calls a normal composable from a read-only one
@Composable
@ReadOnlyComposable
fun computed(): Int = nonReadOnlyHelper()
「追加すべきか」の判断基準
関数本体に次のいずれかが含まれている場合は、@ReadOnlyComposable を追加しないでください。
- レイアウト呼び出し:
Box、Column、Row、LazyColumn、Text、androidx.compose.foundation.layoutまたはandroidx.compose.material*のすべて。 - 副作用の呼び出し:
LaunchedEffect、DisposableEffect、SideEffect、produceState。 remember { … }— 位置ベースのメモ化はコンポジションの状態です。@Composableラムダの呼び出し(content())。@ReadOnlyComposableではないコンポーザブル関数の呼び出し。
関数本体が Local*.current の読み取り、他の @ReadOnlyComposable 関数の呼び出し、または純粋な計算だけであれば、追加してください。
このルールが適用されない場合
override funの宣言。 アノテーションは契約の一部です。基底が@ReadOnlyComposableでなければ、オーバーライドに付けることはできません。基底をリファクタリングするか、オーバーライドでグループ作成コストが発生することを受け入れてください。- 抽象宣言。 確認する関数本体がありません。
関連:副作用は専用のスキルで扱います
コンポーザブルで LaunchedEffect、DisposableEffect、SideEffect、rememberCoroutineScope、rememberUpdatedState、snapshotFlow、スナックバー/ナビゲーションの処理、分析、または Flow の収集が必要な場合は、compose-side-effects を使用してください。
焦点は質問ごとに分かれています。ナビゲーション、フォーカス状態、FocusRequester の所有権、動作 → compose-focus-navigation;命令型の requestFocus を呼び出すタイミング(エフェクトのタイミング、ライフサイクル、キー、API の選択) → compose-side-effects。
このスキルでは、Compose の状態を正しく作成する方法を扱います。rememberUpdatedState はエフェクトで値を取り込むための状態であり、remember { mutableStateOf(...) } の汎用的な代替ではありません。副作用には個別のライフサイクルとキー設定のルールがあります。それらを専用のスキルにまとめることで、信頼できる情報源が二つに分かれるのを防ぎます。
クイックリファレンス
| 症状 | 診断 | 修正 |
|---|---|---|
@Composable fun の関数本体内にある var x = … | 再コンポジションに対応していません(§1) | var x by remember { mutableStateOf(…) } |
Column { … }/Row { … } のコンテンツラムダ内にある var x = … | 同じです。コンテンツラムダは @Composable です(§1) | 同じ修正 |
remember { mutableStateOf(list) } の後に .add(x) を呼び出しても再コンポジションされない | 変更が State のセッターを経由していません | mutableStateListOf を使うか、値を置き換えます:state = state + x |
コンポーザブルの関数本体内で stateMap.clear(); stateMap.putAll(...) を呼び出している | コンポジションからコンポジションへの書き戻し | remember(keys) { derivedSnapshot } |
Text/Box/remember/エフェクトの呼び出しがない @Composable fun | @ReadOnlyComposable を付けられる可能性があります(§2) | @Composable の上に @ReadOnlyComposable を追加 |
Box {}/Column {}/通常のコンポーザブルを呼び出す @ReadOnlyComposable 関数 | 契約違反です(§2) | @ReadOnlyComposable を削除 |
適用しない場合
- テストで
composeTestRule.setContent { … }を使う場合も、同じルールが適用されます。これらは本番環境で使用するコンポーザブルです。 produceStateにはコルーチン内で実行される独自のプロデューサーブロックがあるため、その内部でLaunchedEffectを使う必要はありません。derivedStateOfには安定性や等価性に関する独自の考慮事項があります。ここでは対象外です。これは状態を記述するためではなく、再コンポーズを防ぐためのものです。- 読み取り専用コンポーザブルとして宣言されたものの
overrideでは、アノテーションは基底クラスによって固定されています。ローカルで追加したり削除したりすることはできません。
レビュー時の危険信号
| 考え | 実際のところ |
|---|---|
「小さなコンポーザブルだから、ただの var で問題ない」 | 再コンポーズはいつでも発生する可能性があります。リセットは意図的に非決定的で、バグ報告が1件届けばそれが明らかになります。 |
「関数が単純そうだから @ReadOnlyComposable を追加しよう」 | 基準は「単純かどうか」ではなく、「読み取り専用の呼び出しのみを行うかどうか」です。 |
「知っているのがこれだけだから、いつも LaunchedEffect を使ってしまう」 | compose-side-effects を使ってください。エフェクト API の選択はライフサイクルとキーによって異なります。 |
「覚えているリストに .add() するだけでいい」 | mutableStateOf(List) は内部の変更を監視しません。mutableStateListOf を使うか、値を置き換えてください。 |
「オーバーライドする関数の動作に合わせて @ReadOnlyComposable が必要だ」 | 基底クラスに @ReadOnlyComposable がなければ、オーバーライドに追加することはできません。代わりに基底クラスをリファクタリングしてください。 |