mcpField
C
compose-state-authoring

compose-state-authoring

github.com/chrisbanes開発1.1k994 回インストール1 日前に更新
リポジトリ

compose-state-authoring について

Compose のローカル UI 状態管理と `@ReadOnlyComposable` の適切な使い方を扱うガイドです。再コンポーズでリセットされる状態や更新を通知しない状態を防ぎ、合成状態のみを読み取ると宣言できる条件を説明します。

主な機能

  1. 01`remember` によるローカル状態の保持
  2. 02スナップショット状態による更新通知
  3. 03監視可能な状態コレクションの選択
  4. 04コンポジション中の状態書き戻しの回避
  5. 05契約に沿った `@ReadOnlyComposable` の使用

活用シーン

  1. 01再コンポーズでリセットされるローカル変数の修正
  2. 02コレクション変更の監視可能化
  3. 03入力からの読み取り専用スナップショット生成
  4. 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 については、それぞれのスキルを参照してください。

基本原則

@Composable は、入力が変わるたびにランタイムが再実行する関数です。ローカル状態を正しく記述するには、次の 2 つの問いに答える必要があります。

  1. 可変のローカル状態 — var は再コンポジションをまたいで保持され、かつ再コンポジションを発生させますか?そうでなければ、再コンポジションのたびにひそかにリセットされ、書き込みも認識されません。
  2. これはどの種類のコンポーザブルですか? — コンポジションを変更しますか(レイアウトノードを配置する、スロットを割り当てる、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 がなければ、オーバーライドに追加することはできません。代わりに基底クラスをリファクタリングしてください。

コメント