mcpField
C
choosing-derivedstateof

choosing-derivedstateof

github.com/skydoves開発520131 回インストール19 週間前に更新
リポジトリ

choosing-derivedstateof について

Compose の `derivedStateOf` を使う場面を Claude に案内するスキルです。不要なサブスクリプションや古い値のキャプチャを避け、`snapshotFlow` による副作用処理と再コンポーズの確認も扱います。

主な機能

  1. 01入力と出力の更新頻度を比較
  2. 02キャプチャ値を `remember` のキーに指定
  3. 03副作用に `snapshotFlow` を使用
  4. 04再コンポーズ回数を確認

活用シーン

  1. 01スクロール由来の真偽値をフィルタリングするか判断
  2. 02コンポジション後に更新されない派生値を修正
  3. 03派生値の変化時に一度だけ処理を実行

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/skydoves/compose-performance-skills --skill choosing-derivedstateof

インストール後、Claude Code で/choosing-derivedstateofを入力して読み込まれたか確認します。

詳細

derivedStateOf の選び方 — 頻繁に変化する入力を、変化の少ない出力に絞り込む

derivedStateOf は、入力ステートがはるかに頻繁に変化する場合でも、派生した結果が変化したときにだけ読み取り側が無効化される State を生成します。入力の変化頻度が出力の変化頻度を上回る場合に使用してください。それ以外の用途では、フィルタリングのメリットがないスナップショットサブスクリプションが余分に追加されるだけで、純粋なオーバーヘッドになります。このスキルでは、Claude がいつ使用し、いつ使用を避けるべきか、また初期値のキャプチャによる典型的な落とし穴をどう避けるかを学びます。

このスキルを使う場面

  • 開発者が、値を derivedStateOf でラップすべきかどうかを尋ねている。
  • スクロール位置がブール値を決定する(firstVisibleItemIndex == 0、scrollState.value > threshold、「スクロール時に FAB を表示」)。
  • 変化頻度の高い入力(ドラッグの差分、アニメーションの進行状況)が、変化頻度の低い出力(ブール値のしきい値、区分化された整数)に渡される。
  • コードに derivedStateOf があるものの、再コンポジション回数が減らなかったり、最初のコンポジション後に値が更新されなかったりする(初期値のキャプチャによるバグ)。
  • 派生値が切り替わったときに一度だけ実行する副作用(分析、ログ記録、スナックバー)を開発者が実装したい。

このスキルを使わない場面

  • ステートの読み取りフェーズが間違っている(例:Modifier.alpha(state.value))。まず ../deferring-state-reads/SKILL.md を使って修正してください。derivedStateOf ではフェーズの問題に対処できません。
  • パラメーターが不安定かどうかを診断する。これは ../../stability/diagnosing-compose-stability/SKILL.md が対象です。
  • 入力と出力の変化頻度が同じ(例:2 つの名前ステートから "$first $last" を生成する)。この場合、derivedStateOf は純粋なオーバーヘッドです。直接読み取るか、通常の remember を使ってください。

前提条件

  • ステートの読み取りに基づく再コンポジションを理解していること:再起動可能なコンポーザブル内のスナップショット読み取りによって、そのコンポーザブルの再起動スコープがステートを購読します。
  • Compose runtime ≥ 1.0(この API は基礎的なもので、ここで説明する動作は安定しています)。
  • Layout Inspector または skydoves/compose-stability-analyzer の @TraceRecomposition を使って、再コンポジション回数を確認できること。

ワークフロー

  • 1. 入力ステートと、UI が実際に使用する値を特定します。 両方の更新頻度を書き出します。例:listState.firstVisibleItemIndex(リスト項目をスクロールして通過するたびに変化)→ Boolean(ユーザーがインデックス 0 を越えたときに一度だけ変化)。

  • 2. 頻度を比較します。 次のルールを適用します。

    • 入力の更新頻度が出力よりはるかに高い → derivedStateOf が適切です。
    • 入力と出力がほぼ同じ頻度で更新される → derivedStateOf は意味のないスナップショットサブスクリプションを追加します。直接読み取るか、通常の remember(input1, input2) を使ってください。
  • 3. 導出処理を remember { derivedStateOf { … } } でラップします。 derivedStateOf { } だけではコンポジションのたびに再作成され、キャッシュの効果がなくなります。remember は必須です。

  • 4. ラムダがステートではない変数(関数パラメーター、ローカル変数、プロパティ)をキャプチャする場合は、それらを remember のキーとして渡します。 キャプチャされた非ステート変数は最初のコンポジション時に一度だけ読み取られ、その後は固定されます。派生処理では古い値が気づかれないまま使われます。修正方法は remember(threshold) { derivedStateOf { … > threshold } } です。

  • 5. 派生値に対する一度だけの副作用(ログ記録、分析、スナックバー)には、LaunchedEffect 内の snapshotFlow { … }.collect { … } を優先します。 これにより、コンポーザブルの再起動スコープが派生ステートを購読するのを避けられ、副作用はコンポジション経路外のコルーチンで実行されます。

  • 6. Layout Inspector / @TraceRecomposition で確認します。 利用側コンポーザブルの再コンポジション回数は、入力が変化するたびではなく、派生結果が変化したときにだけ増加しなければなりません。入力が変化するたびに増え続ける場合は、(a) derivedStateOf がない、(b) remember がない、または (c) 兄弟のステート読み取りやフェーズの誤ったモディファイアなど、別の要因が同じスコープを無効化しています。

パターン

パターン:トップへ戻る FAB — 入力 >> 出力
// WRONG
val showFab = listState.firstVisibleItemIndex > 0
// WRONG because: this reads firstVisibleItemIndex on every recomposition; the consuming composable invalidates per scrolled item, not just when the boolean flips.
// RIGHT
val showFab by remember {
    derivedStateOf { listState.firstVisibleItemIndex > 0 }
}

firstVisibleItemIndex はリスト項目を通過するたびに更新されますが、showFab が切り替わるのはユーザーがインデックス 0 をまたぐときだけです。remember によって、派生状態はコンポジションをまたいで保持されます。derivedStateOf は、ブール値が切り替わらない入力変更をすべて除外します。

パターン:純粋なオーバーヘッド — 入力 ≈ 出力
// WRONG
val first by remember { mutableStateOf("Ada") }
val last by remember { mutableStateOf("Lovelace") }
val fullName by remember { derivedStateOf { "$first $last" } }
// WRONG because: first and last change at the same rate as fullName; derivedStateOf adds a snapshot subscription with zero filtering benefit.
// RIGHT — direct read, no derivedStateOf needed
val first by remember { mutableStateOf("Ada") }
val last by remember { mutableStateOf("Lovelace") }
val fullName = "$first $last"

メモ化のコストが懸念される場合(文字列の連結にコストがかかる場合)は、remember(first, last) { computeFullName(first, last) } を使用してください。除外する対象がないため、derivedStateOf は適切ではありません。

パターン:キャプチャされた非 State 変数 — 気づきにくいバグ
// WRONG
@Composable
fun Header(threshold: Int, listState: LazyListState) {
    val isLarge by remember {
        derivedStateOf { listState.firstVisibleItemIndex > threshold }
    }
    // WRONG because: threshold is captured inside remember { ... } at first composition; later threshold changes are ignored, and the derivation reuses the stale value forever.
}
// RIGHT — threshold is a remember key, so a new derivedStateOf is created when it changes
@Composable
fun Header(threshold: Int, listState: LazyListState) {
    val isLarge by remember(threshold) {
        derivedStateOf { listState.firstVisibleItemIndex > threshold }
    }
}

ルール:derivedStateOf のラムダがキャプチャする非 State 値は、すべて外側の remember のキーに MUST として指定してください。そうしないと、派生処理は最初のコンポジション時の値を固定してしまいます。

パターン:一度きりの副作用 → snapshotFlow
// WRONG
@Composable
fun Feed(listState: LazyListState, onScrolledPastFold: () -> Unit) {
    val pastFold by remember {
        derivedStateOf { listState.firstVisibleItemIndex > 5 }
    }
    if (pastFold) onScrolledPastFold()
    // WRONG because: side effects in a composable body run on every (re)composition, can fire multiple times for the same flip, and tie the side effect to the composition lifecycle.
}
// RIGHT — fire-and-forget side effect via snapshotFlow
@Composable
fun Feed(listState: LazyListState, onScrolledPastFold: () -> Unit) {
    LaunchedEffect(listState) {
        snapshotFlow { listState.firstVisibleItemIndex > 5 }
            .distinctUntilChanged()
            .filter { it }
            .collect { onScrolledPastFold() }
    }
}

snapshotFlow はコルーチン内でスナップショット状態を読み取り、読み取りセット全体の値が変化すると値を出力します。LaunchedEffect のキーには listState を指定し、distinctUntilChanged() によって切り替わるたびに一度だけ値が出力されるようにします。再起動スコープは関与しません。このシグナルによって、使用するコンポーザブルが再コンポジションされることはありません。

パターン:多数の入力から 1 つの区分を導出する
// RIGHT
val priceBucket by remember {
    derivedStateOf {
        when {
            cartTotal.value < 10_000 -> Bucket.Small
            cartTotal.value < 50_000 -> Bucket.Medium
            else -> Bucket.Large
        }
    }
}

cartTotal は 1 ウォン/セントずつ変化する場合がありますが、priceBucket が切り替わるのは範囲全体で最大 2 回です。これは典型的なユースケースです。

必須ルール

  • MUST すべての derivedStateOf を remember { … } で囲んでください。単独の derivedStateOf { } はコンポジションごとに再作成されるため、変更を除外できません。
  • MUST キャプチャされた非 State 変数(関数パラメーター、ローカルの vals、props)を remember のキーに追加してください。そうしないと、派生処理は最初のコンポジション時の値を固定してしまいます。
  • MUST NOT 入力と出力の更新頻度が同じ場合に derivedStateOf を使用しないでください。純粋なオーバーヘッドとなるため、直接読み取るか、remember(keys) { … } を使用してください。
  • MUST NOT collectAsState()/collectAsStateWithLifecycle() の結果を「念のため」derivedStateOf で囲まないでください。何も得られないまま、購読レイヤーが追加されます。フロー上流で .distinctUntilChanged() または .map { } を使用して除外してください。
  • PREFERRED: 一度きりの副作用には、derivedStateOf よりも snapshotFlow { … } を優先してください。コンポジションからシグナルを完全に切り離せます。
  • PREFERRED: 変更前と変更後の再コンポジション回数を測定してください。derivedStateOf の目的は回数を減らすことです。減っていない場合は、別の問題があります(多くの場合、フェーズの読み取りが誤っています。../deferring-state-reads/SKILL.md を参照してください)。

検証

  • Layout Inspector で、使用するコンポーザブルの再コンポジションが、導出結果が変化したときだけ発生することを確認してください(例:FAB が表示されたときに 1 回、非表示になったときに 1 回)。入力が更新されるたびに発生してはいけません。
  • ファイル内のすべての derivedStateOf が、remember { … }(または remember(keys) { … })の呼び出し内にあることを確認してください。
  • すべての derivedStateOf ラムダがキャプチャする非 State 変数が remember のキーに含まれているか、ラムダが State オブジェクトのみを読み取ることを確認してください。
  • 導出値に対する副作用が、コンポーザブル本体で派生状態を読み取る方法ではなく、LaunchedEffect { snapshotFlow { … }.collect { } } を通じて実行されることを確認してください。
  • derivedStateOf { を grep し、入力と出力の更新頻度が同じ箇所("$first $last" のような形で使用されている箇所)がないことを確認してください。

参考資料

コメント