はじめに
Jetpack Compose で非同期処理やコルーチンを扱っていると、必ずと言っていいほど登場するのが LaunchedEffect と rememberCoroutineScope です。
どちらも「Compose の中でコルーチンを起動する」ための仕組みですが、起動されるタイミングとライフサイクルが根本的に異なります。この違いを理解せずに使うと、
- 画面回転のたびに API が再度呼ばれてしまう
- ボタンを押したのにアニメーションが動かない
- スナックバーが表示されない/意図せず何度も表示される
といった不具合を引き起こしがちです。本記事では、両者の違いを実際のユースケースを交えながら整理します。
結論を先に
LaunchedEffect |
rememberCoroutineScope |
|
|---|---|---|
| 起動タイミング | Composition 時に 自動で 起動 | ユーザー操作などの イベントに応じて 明示的に起動 |
| 呼び出し場所 | Composable 関数の直下(コンポーザブルスコープ) |
onClick などのラムダ(非コンポーザブルスコープ) |
| 典型用途 | 画面表示時のデータ取得、状態変化の監視 | ボタン押下時のアニメーション実行、Snackbar表示 |
| スコープの寿命 | key が変わるかコンポジションから離脱するとキャンセル | 呼び出し元のコンポーザブルがコンポジションから離脱するとキャンセル |
一言でまとめると、
「Compose が勝手にやってほしい処理」は
LaunchedEffect、「ユーザーが何かした時にやりたい処理」はrememberCoroutineScope
です。
LaunchedEffect: Composition に連動して自動実行
LaunchedEffect は、指定した key が変化したとき(または初回コンポジション時)にコルーチンを起動する Composable 関数です。onClick のような通常のラムダの中では呼び出せず、あくまで Composable 関数のスコープ内でのみ使用できます。
実例: 画面表示時にデータを取得する
@Composable
fun UserProfileScreen(userId: String, viewModel: UserProfileViewModel) {
val uiState by viewModel.uiState.collectAsState()
// userId が変わるたびに再取得したい
LaunchedEffect(userId) {
viewModel.loadUser(userId)
}
when (uiState) {
is UiState.Loading -> LoadingIndicator()
is UiState.Success -> UserProfileContent(uiState.user)
is UiState.Error -> ErrorMessage(uiState.message)
}
}
ポイントは key に userId を渡している点です。もし key1 = Unit にしてしまうと、画面回転などの再コンポジションが起きても再実行されませんが、userId が実際に変わったときには再実行したい、というケースにはこのように意味のある key を渡します。
もう1つ典型的なのが「状態の変化を監視して副作用を起こす」パターンです。
@Composable
fun SearchScreen(viewModel: SearchViewModel) {
val query by viewModel.query.collectAsState()
LaunchedEffect(query) {
// 入力が止まってから少し待ってから検索する(デバウンス)
delay(300)
viewModel.search(query)
}
SearchTextField(query = query, onQueryChange = viewModel::onQueryChange)
}
query が更新されるたびに前回の LaunchedEffect は自動的にキャンセルされ、新しいコルーチンが起動します。これにより、デバウンス処理が簡潔に書けます。手動でキャンセル処理を書く必要がない点が LaunchedEffect の大きな利点です。
rememberCoroutineScope: イベント起点で明示的に実行
一方、rememberCoroutineScope は「コンポジションに紐づいた CoroutineScope を覚えておく」だけの仕組みです。それ自体はコルーチンを起動しません。実際に scope.launch { } を呼んだタイミングでコルーチンが起動します。
実例: ボタン押下でSnackbarを表示する
@Composable
fun SubmitButton(snackbarHostState: SnackbarHostState) {
val scope = rememberCoroutineScope()
Button(
onClick = {
scope.launch {
snackbarHostState.showSnackbar("送信しました")
}
}
) {
Text("送信")
}
}
showSnackbar は suspend 関数なので、onClick のような通常のラムダから直接呼び出すことはできません。onClick は Composable 関数ではないため、そこで LaunchedEffect を使うこともできません。そこで、あらかじめ rememberCoroutineScope で取得しておいたスコープを使ってコルーチンを起動する、という書き方になります。
実例: アニメーションをユーザー操作に応じて実行する
@Composable
fun ExpandableCard() {
val scope = rememberCoroutineScope()
val rotation = remember { Animatable(0f) }
Card(
modifier = Modifier.clickable {
scope.launch {
rotation.animateTo(
targetValue = if (rotation.value == 0f) 180f else 0f,
animationSpec = tween(300)
)
}
}
) {
Icon(
imageVector = Icons.Default.ArrowDropDown,
contentDescription = null,
modifier = Modifier.rotate(rotation.value)
)
}
}
これも「タップされた瞬間にだけ」アニメーションを起動したいケースです。もし仮にこれを LaunchedEffect で書こうとすると、クリックという「イベント」を何らかの State に変換してその変化を監視する、という遠回りな実装が必要になり、コードの見通しが悪くなります。
使い分けを間違えるとどうなるか
アンチパターン1: onClick の中で LaunchedEffect を使おうとする
Button(onClick = {
LaunchedEffect(Unit) { // コンパイルエラー
viewModel.submit()
}
})
LaunchedEffect は @Composable 関数なので、通常のラムダの中には書けません。ボタン押下時の処理は rememberCoroutineScope を使うのが正解です。
アンチパターン2: 副作用のトリガーに rememberCoroutineScope を使う
@Composable
fun UserProfileScreen(userId: String, viewModel: UserProfileViewModel) {
val scope = rememberCoroutineScope()
// 初回にしか呼ばれず、userId が変わっても再取得されない
scope.launch {
viewModel.loadUser(userId)
}
...
}
これは一見動きそうに見えますが、Composable 関数の本体が再コンポジションされるたびに scope.launch が無条件で呼ばれてしまい、意図しない多重実行を招きます。また userId の変化を検知して再取得する、という目的も果たせません。画面表示に連動した処理は LaunchedEffect(key) で書くべきです。
まとめ
-
LaunchedEffectは Composition のライフサイクルに紐づく副作用(データ取得、状態監視など)に使う -
rememberCoroutineScopeは ユーザー操作などの単発イベントに応じてコルーチンを起動したいときに使う - 「起動するトリガーが State/key の変化か、それともイベントか」で判断すると迷いにくい
どちらも Compose の宣言的なライフサイクルと相性の良い設計になっているので、GlobalScope.launch のような自前のスコープ管理をなるべく避け、Compose が用意しているこれらの仕組みに乗ることが、安全な非同期処理への近道です。