Composeのコンポーネントツリーにおけるバケツリレーを回避する方法

深くネストされたUIツリーの可読性と保守性を維持するための4つのComposeパターンを紹介します。

Jetpack Composeでは、小さく再利用可能なコンポーザブルを組み合わせてUIを構築することが推奨されています。しかし、アプリケーションが成長するにつれて、それらのコンポーザブルは自然と深くネスト(階層化)されていくものです。

その結果としてよく起こるのが、「プロップドリル(データのバケツリレー)」です。これは、特定のデータやコールバックを、それらを実際には必要としない中間層のコンポーザブルをいくつも経由して、下層へと引き渡していく現象を指します。


Parent
  ↓
Root
  ↓
Content
  ↓
Card
  ↓
UserName

この例では、RootContentCard は単に UserName にパラメータを転送しているだけです。彼ら自身はそのデータを利用しておらず、単なる「中継役」として機能しています。

Composeがこの問題を完全に消し去ってくれるわけではありませんが、問題を軽減または回避するための洗練された方法がいくつか用意されています。今回は、私が特によく使う4つのパターンを見ていきましょう。

 

🤔 レイアウト用コンポーネントには「Slot API」を使う

中間層のコンポーザブルが単にレイアウト(配置)を定義しているだけなら、通常は「Slot API」を使うのがもっともクリーンな解決策です。

対応前
すべてのコンポーザブルが、同じパラメータをひたすらバケツリレーしています。


@Composable
fun ParentScreen() {
    val userName = "Alice"

    Root(userName)
}

@Composable
fun Root(userName: String) {
    Content(userName)
}

@Composable
fun Content(userName: String) {
    Card(userName)
}

@Composable
fun Card(userName: String) {
    UserName(userName)
}

@Composable
fun UserName(userName: String) {
    Text(userName)
}

実際には、UserName だけが userName を必要としています。

対応後
代わりに、親(呼び出し側)に子コンポーザブルを組み立てさせます。


@Composable
fun ParentScreen() {
    val userName = "Alice"

    CardLayout {
        UserName(userName)
    }
}

@Composable
fun CardLayout(
    content: @Composable () -> Unit
) {
    Card {
        content()
    }
}

これでレイアウト用コンポーネント CardLayout は、userName について何も知る必要がなくなりました。単に、受け取ったコンテンツを「どこに表示するか」を決めているだけです。

ScaffoldLazyColumnButton といったComposeの標準APIが、何十個ものパラメータを個別に公開するのではなく、スロット(content ラムダ)を採用しているのはまさにこれが理由です。

コンポーザブルの役割が「データ」の処理ではなく「レイアウト」である場合は、いつでもSlot APIの採用を検討しましょう。

 

🤔 共有オブジェクトには「CompositionLocal」を使う

オブジェクトの中には、特定のUIブランチ(階層)だけでなく、コンポジション全体で共有されるべきものがあります。

たとえば以下のようなものです。

- Navigator(画面遷移)
- Theme(テーマ・デザインシステム)
- Analytics(ログ分析ツール)
- User session(ユーザーセッション情報)
- Density(画面密度)

これらをすべてのコンポーザブルに引数で渡そうとすると、すぐに同じコードの繰り返しになってしまいます。

対応前


@Composable
fun ParentScreen() {
    Root(navigator)
}

@Composable
fun Root(navigator: Navigator) {
    Content(navigator)
}

@Composable
fun Content(navigator: Navigator) {
    Detail(navigator)
}

@Composable
fun Detail(navigator: Navigator) {
    Button(
        onClick = { navigator.pop() }
    ) {
        Text("Back")
    }
}

対応後


// 1. CompositionLocalを定義する
val LocalNavigator = staticCompositionLocalOf<Navigator> { 
    error("No Navigator provided") 
}

@Composable
fun ParentScreen() {
    // 2. 最上位で値をプロバイドする
    CompositionLocalProvider(LocalNavigator provides navigator) {
        Root()
    }
}

@Composable fun Root() { Content() }
@Composable fun Content() { Detail() }

@Composable
fun Detail() {
    // 3. 必要な場所で直接呼び出す
    val navigator = LocalNavigator.current
    Button(
        onClick = { navigator.pop() }
    ) {
        Text("Back")
    }
}

これにより、中間層にあるすべてのコンポーザブルが依存関係のチェーンから解放され、コードがすっきりします。

ただし、トレードオフとして「依存関係が暗黙的(コードの表面上は見えにくく)になる」という点には注意が必要です。そのため、CompositionLocal の使用は、UIの大部分で本当に広く共有される値だけに限定するのがベストです。

 

🤔 状態(State)とイベント(Event)をまとめる

プロップドリルが問題になるのは、状態(データ)を渡すときだけではありません。

実は、コールバック(関数)のバケツリレーのほうが、より大きな問題になりがちです。

対応前
引数が増えるたびに、中間層のすべてのコンポーザブルで同じコールバックを転送し続けなければなりません。


Child(
    userName = state.userName,
    isLoading = state.isLoading,
    onRefresh = viewModel::refresh,
    onRetry = viewModel::retry,
    onDelete = viewModel::delete,
    onRename = viewModel::rename,
    onLogout = viewModel::logout
)


@Composable
fun Content(
    userName: String,
    isLoading: Boolean,
    onRefresh: () -> Unit,
    onRetry: () -> Unit,
    onDelete: () -> Unit,
    onRename: (String) -> Unit,
    onLogout: () -> Unit
) {
    Child(
        userName,
        isLoading,
        onRefresh,
        onRetry,
        onDelete,
        onRename,
        onLogout
    )
}

対応後
複数のコールバックを個別に公開するのではなく、単一の「イベントディスパッチャー(イベント通知用ラムダ)」にまとめます。


sealed interface ScreenEvent {
    data object Refresh : ScreenEvent
    data object Retry : ScreenEvent
    data object Delete : ScreenEvent
    data object Logout : ScreenEvent
    data class Rename(val name: String) : ScreenEvent
}


Child(
    state = state,
    onEvent = viewModel::onEvent
)


Button(
    onClick = {
        onEvent(ScreenEvent.Refresh)
    }
) {
    Text("Refresh")
}

これにより、コンポーザブルが公開するAPI(引数)が圧倒的にすっきりします。さらに、新しいユーザーアクションを追加したくなったときも、中間層の関数をすべて書き直す必要がなくなるのが大きなメリットです。

 

🤔 巨大なViewModelを分割する

時には、プロップドリル(バケツリレー)が根本的な原因ではなく、別の問題から生じている「症状」に過ぎないこともあります。

もし、単一の ScreenViewModel が画面全体のあらゆる状態(State)を管理しているとしたら、すべてのデータがその1つのオブジェクトから流れ出すことになるため、バケツリレーが発生するのは当然と言えます。

対応前
すべての下層コンポーネントが、同じ単一のViewModelに依存しています。


ScreenViewModel
  ↓
Screen
├── Toolbar
├── Content
│   ├── Tab
│   │   ├── BottomSheet
│   │   └── Dialog

対応後
ViewModelを分割し、UIの各パーツにスコープを合わせます。


ScreenViewModel
  ↓
Screen
├── Toolbar
├── Content
│   ├── Tab
│   │
│   ├── BottomSheetViewModel
│   │     ↓
│   │   BottomSheet
│   │
│   └── DialogViewModel
│         ↓
│       Dialog


@Composable
fun BottomSheet() {
    val viewModel: BottomSheetViewModel = viewModel()

    val state by viewModel.state.collectAsState()

    // ...
}

Navigation 3やネストされたナビゲショングラフ(Nested Navigation Graphs)の登場により、UIのより小さな単位(パーツ)に対してViewModelのスコープを制限することが、以前よりもはるかに簡単になりました。

その結果、不要な共有状態(State)が減り、プロップドリルも大幅に解消されます。

 

🧑🏻‍💻 まとめ

プロップドリル(データのバケツリレー)が起きているからといって、必ずしも設計(アーキテクチャ)が悪いとは限りません。UIツリーが成長していく過程で、自然と発生してしまうケースも多々あります。

ここで重要になるのは、「なぜそのデータが、これほど多くの階層を経由しているのか」を掘り下げて考えることです。

これらのパターンは、どれか一つしか選べないというものではありません。事実、洗練されたComposeのコードベースでは、適材適所でこれら4つのアプローチがすべて組み合わされて使われています。


Jetpack Compose で Google Analytics を使うなら、MVVM に乗せる意味はほとんどない

UIイベントをViewModel経由にしても責務は増えるだけ。Composeの副作用で完結させたほうがシンプルになる。

 

🧑🏻‍💻 「AnalyticsはViewModelから送るべき」という思い込み

Jetpack Composeでは、ボタンタップや画面表示などのイベントをGoogle Analyticsへ送る機会が多くあります。

しかし、多くのMVVMサンプルでは次のようなコードになっています。


Button
   │
   ▼
ViewModel.onClick()
   │
   ▼
Analytics.log(...)

一見MVVMらしく見えます。

ですが、本当にViewModelが必要なのでしょうか。

Composeでは、多くの場合答えは No です。

 

🧑🏻‍💻 AnalyticsはUIイベントである

Google Analyticsが知りたいのは

- ボタンが押された。
- 画面が表示された。
- ダイアログが開いた。
- スクロールした。
- タブが切り替わった。

つまり

「UIで何が起きたか」

です。

これはRepositoryのデータでもなく、
Business Logicでもありません。

純粋なUIイベントです。

 

🧑🏻‍💻 ViewModelは何も判断していない

例えば


fun onFavoriteClick() {
    analytics.log("favorite")
}

これだけなら

ViewModelは

- 状態も持たない。
- ロジックもない。
- ただ転送しているだけ。

です。

実質


Button
   │
   ▼
Analytics

との違いがありません。

 

🧑🏻‍💻 Composeならその場で書ける


Button(
    onClick = {
        analytics.logEvent("favorite")
        onFavorite()
    }
) {
    Text("Favorite")
}

これだけです。

余計な

- Event
- Action
- Intent
- ViewModel関数

は不要です。

 

🧑🏻‍💻 画面表示も同じ

よくある実装


LaunchedEffect(Unit) {
    viewModel.onScreenShown()
}

ViewModel


fun onScreenShown() {
    analytics.logScreen("Home")
}

ですが、

Composeなら


LaunchedEffect(Unit) {
    analytics.logScreen("Home")
}

だけで終わります。

ViewModelは何もしていません。

 

🧑🏻‍💻 スクロールイベントも同じ

Composeでは


LaunchedEffect(listState) {
    snapshotFlow {
        listState.firstVisibleItemIndex
    }.collect {
        analytics.logScroll(it)
    }
}

で十分です。

これを


snapshotFlow
      │
      ▼
ViewModel
      │
      ▼
Analytics

にする理由はありません。

 

🧑🏻‍💻 MVVMに乗せることで増えるもの


UI
 │
 ▼
ViewModel
 │
 ▼
Analytics

追加されるもの

- Eventクラス
- ViewModel関数
- DI
- テスト対象
- 呼び出し経路

しかし

増える責務はありません。

 

🧑🏻‍💻 ViewModelはUIの代理人ではない

ViewModelの役割は

- UI Stateを持つ。
- Business Logicを実行する。
- Repositoryと連携する。

ことです。

Analytics送信だけのために


fun onButtonClick()
fun onFabClick()
fun onBackPressed()
fun onTabSelected()
fun onDialogOpened()

を大量に並べると、

ViewModelは単なるイベント中継器になります。

 

🧑🏻‍💻 Composeには副作用APIがある

Composeには

- LaunchedEffect
- DisposableEffect
- SideEffect
- snapshotFlow

があります。

これらは

UIから外部世界へ何かを通知する

ために存在しています。

Analytics送信はまさに副作用です。

Compose自身が用意している仕組みを使う方が自然です。

 

🧑🏻‍💻 UIイベントはUIで完結させる

例えば


Button(
    onClick = {
        analytics.logEvent("purchase")
        onPurchase()
    }
)

これだけです。

ViewModelを経由する理由はありません。

 

🧑🏻‍💻 ではViewModelで送るべきケースは?

もちろんあります。

例えば


purchaseRepository.purchase()

analytics.logPurchase(
    price,
    currency
)

購入成功後に

- 金額
- 商品ID
- 購入結果

など

Business Logicの結果

を送る場合です。

この情報はUIでは分かりません。

このようなAnalyticsはViewModelやUseCaseから送るべきです。

 

🧑🏻‍💻 判断基準

 

🧑🏻‍💻 一つの基準

次の質問をすると判断しやすくなります。

このイベントはUIだけで完結しているか?

YESなら

Composeから直接送る。

NOなら

ViewModelやUseCaseから送る。

これだけです。

 

🧑🏻‍💻 まとめ

Composeでは、副作用を扱うためのAPIが最初から用意されています。

Google Analyticsのような「UIで何が起きたか」を記録する処理は、その場で副作用として送るほうがシンプルです。

一方で、ビジネスロジックの結果やRepositoryの処理結果に基づくAnalyticsは、ViewModelやUseCaseが適切な責務を持ちます。

MVVMは「すべての処理をViewModel経由にする」ための設計ではありません。

Analyticsを送る場所も、「誰がその情報を知っているか」で決めるべきです。


Compose State を Flow に変換する唯一の方法、それが snapshotFlow

snapshotFlow がなぜCompose専用に用意されているのか、その仕組みと実践的な使い方を紹介します。

Jetpack ComposeにはFlowを扱う機会が数多くあります。

Repositoryからデータを受け取るために Flow を collect したり、ViewModel が公開する StateFlowcollectAsState() したりすることは、すでに日常的なパターンになっています。

一方で、Compose State を逆に Flow へ変換したいと思ったことはないでしょうか。

例えば、

- スクロール位置を監視したい。
- 選択中のタブが変わったことを Analytics へ送信したい。
- TextField の入力を debounce() したい。
- Compose State を Flow の演算子で加工したい。

このような場面で登場するのが snapshotFlow です。

そして実は、Compose StateをFlowへ変換するCompose専用のAPIは snapshotFlow だけです。

 

🧑🏻‍💻 なぜflow {}ではダメなのか

最初に思い付くのは、普通の flow ではないでしょうか。


flow {
    emit(state.value)
}

もちろんこれは動きます。

しかし、一度値を送信するだけです。

その後 state.value が変化しても、新しい値は流れません。

なぜなら、flow {} はCompose Stateの変更を監視する仕組みを持っていないからです。

 

🧑🏻‍💻 snapshotFlow は Compose Snapshot を監視する

snapshotFlow は Compose の Snapshot システムと統合されています。


LaunchedEffect(Unit) {
    snapshotFlow {
        listState.firstVisibleItemIndex
    }.collect { index ->
        println(index)
    }
}

ラムダ内で読み取った Compose State を Compose 自身が監視し、

値が変化すると新しい値を Flow へ流します。

つまり、自分で emit() を書く必要はありません。

 

🧑🏻‍💻 イメージするとこうなる


Compose State
     │
     ▼
Compose Snapshot
     │
     ▼
snapshotFlow
     │
     ▼
  Kotlin Flow
     │
     ▼
map / filter / debounce / collect

Compose の世界と Flow の世界をつないでいるのが snapshotFlow です。

 

🧑🏻‍💻 Cold Flowであることも重要

snapshotFlow は Cold Flow です。

つまり、


val flow = snapshotFlow {
    state.value
}

これだけでは監視は始まりません。

実際に監視が始まるのは collect() された瞬間です。


LaunchedEffect(Unit) {
    snapshotFlow {
        state.value
    }.collect {
        // Side Effect
    }
}

そのため、Compose では LaunchedEffect と組み合わせて使うのが一般的です。

 

🧑🏻‍💻 実践例① スクロール位置を監視する

最もよく使われる例です。


LaunchedEffect(Unit) {
    snapshotFlow {
        listState.firstVisibleItemIndex
    }.collect(viewModel::onScrollChanged)
}

例えば、

- Toolbarの表示・非表示
- FABの表示切り替え
- Analytics送信

などに利用できます。

 

🧑🏻‍💻 実践例② Analyticsを送信する

Compose State の変化をイベントとして扱えます。


LaunchedEffect(Unit) {
    snapshotFlow {
        selectedTab
    }.collect(analytics::logTabSelected)
}

UIロジックを汚さず、副作用だけを分離できます。

 

🧑🏻‍💻 実践例③ Flow演算子を組み合わせる

snapshotFlow は通常の Flow なので、そのまま演算子を利用できます。


LaunchedEffect(Unit) {
    snapshotFlow {
        query
    }
        .debounce(300)
        .distinctUntilChanged()
        .collect(viewModel::search)
}

Compose Stateを、そのままリアクティブな Flow パイプラインへ接続できます。

 

🧑🏻‍💻 snapshotFlow が監視するのは Compose State だけ

重要なのは、監視対象はラムダ内で読み取ったCompose Stateだけという点です。


snapshotFlow {
    listState.firstVisibleItemIndex
}

このようなCompose Stateは監視できます。

一方、


var count = 0

snapshotFlow {
    count
}

通常の変数は Compose Snapshot が管理していないため、変更しても Flow は新しい値を流しません。

 

🧑🏻‍💻 まとめ

Compose にはさまざまな Flow API があります。

しかし、Compose State を Flow へ変換するために設計された Compose 専用 API は snapshotFlow だけです。

その役割は単に State を Flow へ変換することではありません。

Compose Snapshot とKotlin Flow を橋渡しし、

- スクロール監視
- Analytics
- TextField の入力監視
- Flow 演算子との連携

など、Compose で副作用を書くための基盤となるAPIです。

snapshotFlow を理解すると、「Compose State を Flowとして扱う」という考え方が自然になり、Composeらしい副作用の書き方が身に付くはずです。


【Jetpack Compose】スクロール位置を正しく復元する

スクロール位置の復元は、一見するととても簡単そうに見えます。


val gridState = rememberLazyStaggeredGridState( 
    initialFirstVisibleItemIndex = savedIndex, 
    initialFirstVisibleItemScrollOffset = savedOffset
)

しかし、この方法が正しく動作するのは、すでにアイテムのレイアウトが完了している場合だけです。

データを非同期で読み込む画面では、LazyVerticalStaggeredGrid は最初はアイテム数が 0 の状態で生成されることが多くあります。

そのため、指定した初期スクロール位置は反映されません。

この問題を解決するため、多くの開発者は LaunchedEffect の中で scrollToItem() を呼び出します。


LaunchedEffect(Unit) { 
    gridState.scrollToItem(savedIndex, savedOffset)
}

しかし、これにも問題があります。

LaunchedEffect はコンポーズ直後に実行されるため、レイアウトがまだ完了していないタイミングで scrollToItem() が呼ばれてしまう可能性があります。

 

🧑🏻‍💻 グリッドの準備が完了するまで待つ

レイアウトの完了タイミングを推測するのではなく、目的のアイテムが実際にレイアウトされるまで待機するのが確実です。


LaunchedEffect(Unit) { 
    val savedPosition = viewModel.savedScrollPosition

    // 目的のアイテムがレイアウトされるまで待機 
    snapshotFlow { gridState.layoutInfo.totalItemsCount }
        .first { it > savedPosition.index }
 
    gridState.scrollToItem(savedPosition.index, savedPosition.offset) 
}

このコードでは、layoutInfo.totalItemsCount が保存していたインデックスより大きくなるまで処理を一時停止します。

つまり、復元したいアイテムが実際にレイアウトされたことを確認してから scrollToItem() を実行するため、タイミングに依存せず、安定してスクロール位置を復元できます。

 

🧑🏻‍💻 参考


Navigation3 時代のSavedStateHandleを考え直す

Jetpack Navigation 3が登場し、画面遷移の設計は大きく変わろうとしています。

その中でも特に変化が大きいのが、ViewModelでSavedStateHandleから画面引数を取得するという、これまで当たり前だった実装です。


@HiltViewModel 
class UserViewModel @Inject constructor(
    savedStateHandle: SavedStateHandle
) : ViewModel() { 
    val userId: String = checkNotNull(savedStateHandle["userId"]) 
}

これまで多くのサンプルや公式ドキュメントでも紹介されてきたため、この書き方に違和感を持つ人は少ないでしょう。

しかしNavigation 3では、この設計を前提としなくてもよくなりました。

むしろ、画面引数は直接ViewModelへ渡すほうが自然だという考え方へ移りつつあります。

 

🧑🏻‍💻 SavedStateHandleは本来何のためのAPIなのか

SavedStateHandleは、プロセスデス時の状態復元を目的として作られたAPIです。

Androidではメモリ不足になるとアプリのプロセスが終了されます。

その際、入力途中の内容やスクロール位置などをBundleへ保存し、アプリ復元時に元へ戻せるようにする仕組みがあります。

SavedStateHandleは、その保存領域へアクセスするためのラッパーです。

つまり、本来の役割は

- テキスト入力
- スクロール位置
- 一時的なUI状態

などを保持することであり、

画面引数を受け渡すためのAPIではありません。

 

🧑🏻‍💻 Navigation 2では便利だった

Navigation Composeでは画面引数が自動的にSavedStateHandleへ入るため、


val userId = savedStateHandle["userId"]

だけで取得できました。

非常に便利だった反面、この実装には問題もありました。

ViewModelが

- 引数名
- Navigationの仕様
- ルート構造

まで知る必要があったからです。

本来ビジネスロジックだけを持つはずのViewModelが、Navigationへ依存してしまいます。

 

🧑🏻‍💻 Navigation 3では考え方が変わった

Navigation 3では画面遷移が型安全になりました。

例えば


@Serializable
data class UserScreen(
    val userId: String
)

のようにルート自体がオブジェクトになります。

Composableでは


fun UserScreen(
    route: UserScreen
)

として受け取れるため、

ViewModelにもそのまま渡せます。


@HiltViewModel(assistedFactory = Factory::class)
class UserViewModel @AssistedInject constructor(
    @Assisted
    private val route: UserScreen
) : ViewModel()

これなら

- 文字列キーが不要
- 型安全
- Navigationへの依存が少ない

というメリットがあります。

 

🧑🏻‍💻 ViewModelはNavigationを知らなくていい

以前は


Navigation
    ↓
SavedStateHandle
    ↓
ViewModel

という流れでした。

Navigation 3では


Navigation
    ↓
Route
    ↓
ViewModel

という構成になります。

ViewModelは必要なデータだけを受け取り、Navigationの実装を意識しなくて済みます。

責務も明確になります。

 

🧑🏻‍💻 SavedStateHandleが不要になったわけではない

もちろん、SavedStateHandle自体が不要になるわけではありません。

例えば

- 検索キーワード
- フォーム入力
- スクロール位置
- タブ選択状態

など、プロセスデス後も復元したいUI状態には引き続き最適です。

つまり、

- 画面引数
- UI状態

を分けて考えることが重要になります。

 

🧑🏻‍💻 これからの設計

Navigation 2では

「画面引数=SavedStateHandle」

という書き方が一般的でした。

しかしNavigation 3では、

- Routeをそのまま渡す
- Assisted Injectionを利用する
- SavedStateHandleはUI状態だけに使う

という設計のほうがシンプルで責務も明確になります。

すぐに既存コードを書き換える必要はありませんが、新規実装ではこの考え方を取り入れていく価値は十分にあるでしょう。

Navigation 3は単なるAPIの更新ではなく、ViewModelとNavigationの関係そのものを見直すきっかけになりそうです。

 

🧑🏻‍💻 参考