--- id: android-compose-effect-patterns title: Compose Effects — LaunchedEffect / DisposableEffect / etc category: Coding status: draft source_trust_level: B verification_status: conceptual created_at: 2026-05-09 updated_at: 2026-05-09 tags: [android, compose, vibe-coding] tech_stack: { language: "Kotlin", applicable_to: ["Android"] } applied_in: [] aliases: [LaunchedEffect, DisposableEffect, SideEffect, rememberCoroutineScope, snapshotFlow, derivedStateOf] --- # Compose Effects > Compose 의 lifecycle / side-effect 다루기. **LaunchedEffect (suspend), DisposableEffect (cleanup), SideEffect (every recompose), rememberCoroutineScope**. 잘못 = recomposition 폭발 / leak. ## 📖 핵심 개념 - Composable 가 pure (no side effect). - Side effect = effect API. - Key 가 effect re-run trigger. - Cleanup = onDispose. ## 💻 코드 패턴 ### LaunchedEffect (suspend, 1번) ```kotlin @Composable fun UserScreen(userId: String) { var user by remember { mutableStateOf(null) } LaunchedEffect(userId) { // userId 변경 = 다시 실행 user = api.fetchUser(userId) } Text(user?.name ?: "Loading...") } ``` → Coroutine. Composable 가 떠나면 자동 cancel. ### DisposableEffect (cleanup) ```kotlin @Composable fun OrientationListener() { val context = LocalContext.current DisposableEffect(Unit) { val listener = object : OrientationEventListener(context) { override fun onOrientationChanged(orientation: Int) { ... } } listener.enable() onDispose { listener.disable() } } } ``` → 매 외부 resource 가 cleanup. ### SideEffect (매 recompose) ```kotlin @Composable fun Screen(state: ScreenState) { SideEffect { // 매 successful recompose 시 analytics.track("Screen viewed", state.toMap()) } } ``` → 거의 X. Specific 외부 sync 만. ### rememberCoroutineScope ```kotlin @Composable fun ButtonScreen() { val scope = rememberCoroutineScope() Button(onClick = { scope.launch { delay(1000) doWork() } }) { Text("Click") } } ``` → Event handler 안에서 coroutine. ### LaunchedEffect vs rememberCoroutineScope ``` LaunchedEffect: - Composable 안 (key 의존) - Composable 떠나면 cancel - Initial / dependency 변경 시 rememberCoroutineScope: - Event handler (onClick) - Composable 떠나면 cancel - 사용자 action 시 ``` ### Multiple keys ```kotlin LaunchedEffect(userId, page) { // userId 또는 page 변경 = re-launch items = api.fetchItems(userId, page) } ``` ### 함정: key 가 Unit ```kotlin LaunchedEffect(Unit) { api.fetchUser(userId) // ❌ userId 변경 시 안 다시 } // 의도가 "1번만" = OK. 그렇지 않으면 위험. ``` ### derivedStateOf ```kotlin val items by remember { mutableStateOf(longList) } val hasMore by remember { derivedStateOf { items.size > 100 } } // items 변경 시 만 재계산. // hasMore 가 변경 안 = 의존 composable 재실행 X. ``` → Computed value 의 memoize. ### snapshotFlow (state → flow) ```kotlin LaunchedEffect(Unit) { snapshotFlow { listState.firstVisibleItemIndex } .distinctUntilChanged() .collect { idx -> log("scrolled to $idx") } } ``` → Compose state 가 Flow. ### produceState ```kotlin @Composable fun loadUser(id: String): State { return produceState(initialValue = null, id) { value = api.fetchUser(id) } } @Composable fun UserCard(id: String) { val user by loadUser(id) Text(user?.name ?: "Loading") } ``` → Async data → State. Custom hook 식. ### remember vs rememberSaveable ```kotlin var count by remember { mutableStateOf(0) } // → process death 시 잃음. var count by rememberSaveable { mutableStateOf(0) } // → bundle 에 저장. Process death 후 복구. ``` → Critical state = saveable. ### Custom saver ```kotlin val UserSaver = listSaver( save = { listOf(it.id, it.name) }, restore = { User(id = it[0] as String, name = it[1] as String) } ) var user by rememberSaveable(stateSaver = UserSaver) { mutableStateOf(initialUser) } ``` ### LaunchedEffect with dependency on changing state ```kotlin @Composable fun Screen() { val viewModel: MyViewModel = viewModel() val state by viewModel.state.collectAsState() // ❌ Effect 가 state 변경 시 매번 — 의도 X. LaunchedEffect(state) { if (state.shouldShowToast) showToast(...) } // ✅ Discrete event 는 channel / flow. LaunchedEffect(Unit) { viewModel.events.collect { event -> when (event) { is Event.ShowToast -> showToast(event.message) } } } } ``` → Event 가 state 와 다름. ### Channel-based event ```kotlin class MyViewModel : ViewModel() { private val _events = Channel() val events = _events.receiveAsFlow() fun login() { viewModelScope.launch { _events.send(Event.ShowToast("Logged in")) } } } ``` → One-shot event. ### LifecycleEventEffect (Compose 1.7+) ```kotlin LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { log("resumed") } LifecycleStartEffect(Unit) { log("started") onStopOrDispose { log("stopped") } } ``` → Activity / Fragment lifecycle awareness. ### Pull to refresh ```kotlin @Composable fun ItemList(viewModel: ItemViewModel) { val state by viewModel.state.collectAsState() val pullRefreshState = rememberPullRefreshState( refreshing = state.isRefreshing, onRefresh = viewModel::refresh, ) Box(Modifier.pullRefresh(pullRefreshState)) { LazyColumn { ... } PullRefreshIndicator(state.isRefreshing, pullRefreshState, Modifier.align(Alignment.TopCenter)) } } ``` ### 함정: leak via remember ```kotlin @Composable fun Screen() { // ❌ Activity / Context 가 remember val context = LocalContext.current val obj = remember { ContextHelper(context) } // → context 가 변경 시 stale. } // ✅ context 가 key val obj = remember(context) { ContextHelper(context) } ``` ### Recomposition 폭발 함정 ```kotlin @Composable fun Screen() { val state by viewModel.state.collectAsState() // ❌ 매 recompose 시 새 lambda Button(onClick = { viewModel.action() }) { ... } // ✅ remember + ref 안정 val onAction = remember { { viewModel.action() } } Button(onClick = onAction) { ... } } ``` → Compose Compiler (Strong skipping mode 1.6.0+) 가 자동 fix. ### State hoisting + effect ```kotlin // Stateless composable @Composable fun MyButton(text: String, onClick: () -> Unit) { Button(onClick = onClick) { Text(text) } } // Stateful caller @Composable fun Screen() { var count by remember { mutableStateOf(0) } MyButton(text = "$count", onClick = { count++ }) } ``` → Effect 가 stateful caller — child 는 pure. ### Compose Multiplatform (KMP) ```kotlin // 같은 effect API, 다른 platform. // → iOS / Android / Desktop / Web 가 같은 코드. ``` → Compose Multiplatform 가 Compose 의 cross-platform. ### Test ```kotlin @get:Rule val composeRule = createComposeRule() @Test fun loads_user() { composeRule.setContent { UserScreen(userId = "1") } composeRule.onNodeWithText("Loading").assertExists() composeRule.onNodeWithText("Alice").assertExists() // wait for effect } ``` ### Performance ``` LaunchedEffect: 매 key 변경 = re-launch. SideEffect: 매 recompose = 호출 (성능 주의). DisposableEffect: re-key 시 dispose + create. → 매 effect 의 cost 인지. ``` ### iOS / SwiftUI 비교 ``` iOS: - onAppear / onDisappear - task { } - onChange(of:) Compose: - LaunchedEffect - DisposableEffect - key 가 변경 trigger → Compose 가 더 명시적 + flexible. ``` ## 🤔 의사결정 기준 | 작업 | API | |---|---| | Async load | LaunchedEffect | | Cleanup resource | DisposableEffect | | Every recompose | SideEffect (rare) | | Event handler | rememberCoroutineScope | | Async → state | produceState | | State → flow | snapshotFlow | | Computed | derivedStateOf | | Lifecycle | LifecycleEventEffect | | State 보존 | rememberSaveable | ## ❌ 안티패턴 - **Composable 안 직접 launch**: leak. - **LaunchedEffect(Unit) + dynamic dep**: stale. - **State 가 event**: Channel 사용. - **rememberCoroutineScope 가 LaunchedEffect 대체**: scope 같지만 의미 다름. - **No key**: 매 recompose 다시 실행. - **SideEffect 안 비싼 work**: 성능. - **DisposableEffect cleanup 없음**: leak. ## 🤖 LLM 활용 힌트 - LaunchedEffect 가 가장 자주. - DisposableEffect 가 외부 resource. - Event 는 Channel / Flow (state X). - derivedStateOf 가 computed memoize. ## 🔗 관련 문서 - [[Android_Compose_State_Hoisting]] - [[Android_Compose_Recomposition_Pitfalls]] - [[React_useEffect_Pitfalls]]