Compose has one rule: Composable functions should be pure. They take state in and produce UI out. No database calls. No API requests. No timers. No logging.

But real apps need those things. You need to start a timer when a screen opens. You need to load data when an ID changes. You need to clean up a listener when a screen closes.

That is what side effects are for. They let you safely run “impure” code inside Compose.

What is a Side Effect?

A side effect is any code that changes something outside the Composable function. Examples:

  • Making an API call
  • Writing to a database
  • Starting a timer
  • Showing a snackbar
  • Logging analytics events
  • Registering a listener

These are dangerous in Compose because Composables can be called many times (recomposition). If you put an API call directly in a Composable, it would run on every recomposition — maybe 10 times per second.

Side effect APIs give you control over when the code runs.

LaunchedEffect — Run Code When Something Changes

LaunchedEffect runs a block of code when the Composable enters the composition. It also reruns when its key changes.

Run Once When Screen Opens

@Composable
fun AnalyticsScreen() {
    // Unit as key = runs only once when the screen first appears
    LaunchedEffect(Unit) {
        analytics.logScreenView("home")
    }

    Text("Home Screen")
}

Unit as the key means “never change” — the code runs once and never reruns.

Run When a Value Changes

@Composable
fun UserDetailScreen(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }

    // Reruns every time userId changes
    LaunchedEffect(userId) {
        user = repository.getUser(userId)  // Suspend function
    }

    if (user != null) {
        Text("Name: ${user?.name.orEmpty()}")
    } else {
        CircularProgressIndicator()
    }
}

When userId changes (user navigates to a different profile), LaunchedEffect cancels the old coroutine and starts a new one. This is safe — no duplicate API calls.

Show a Snackbar

A common use case — show a snackbar when an error happens:

@Composable
fun MyScreen(viewModel: MyViewModel = viewModel()) {
    val state by viewModel.state.collectAsStateWithLifecycle()
    val snackbarHostState = remember { SnackbarHostState() }

    // Show snackbar when error changes
    LaunchedEffect(state.error) {
        state.error?.let { error ->
            snackbarHostState.showSnackbar(error)
        }
    }

    Scaffold(snackbarHost = { SnackbarHost(snackbarHostState) }) { padding ->
        // Screen content
    }
}

Key Rules for LaunchedEffect

KeyBehavior
UnitRuns once, never reruns
A variable (e.g., userId)Reruns when that variable changes
Multiple keysReruns when ANY key changes
// Multiple keys — reruns when either changes
LaunchedEffect(userId, isLoggedIn) {
    if (isLoggedIn) {
        loadUserData(userId)
    }
}

DisposableEffect — Clean Up When Leaving

DisposableEffect is like LaunchedEffect but with a cleanup function. Use it when you need to undo something when the Composable leaves the screen.

Register and Unregister a Listener

@Composable
fun LocationScreen() {
    val context = LocalContext.current

    DisposableEffect(Unit) {
        // This runs when the Composable enters
        val locationManager = context.getSystemService(Context.LOCATION_SERVICE)
            as LocationManager

        val listener = LocationListener { location ->
            // Handle location update
        }

        // Start listening (requires ACCESS_FINE_LOCATION permission)
        // See Tutorial #19 for permission handling
        locationManager.requestLocationUpdates(
            LocationManager.GPS_PROVIDER,
            1000L,
            0f,
            listener
        )

        // onDispose runs when the Composable leaves the screen
        onDispose {
            locationManager.removeUpdates(listener)
        }
    }

    Text("Tracking location...")
}

The onDispose block is required. It runs when:

  • The Composable leaves the composition (screen closes)
  • The key changes (old effect is cleaned up, new one starts)

Lifecycle Observer

A common pattern — react to app lifecycle events:

@Composable
fun LifecycleAwareScreen() {
    val lifecycleOwner = LocalLifecycleOwner.current

    DisposableEffect(lifecycleOwner) {
        val observer = LifecycleEventObserver { _, event ->
            when (event) {
                Lifecycle.Event.ON_RESUME -> {
                    // Screen became visible — start updates
                }
                Lifecycle.Event.ON_PAUSE -> {
                    // Screen hidden — pause updates
                }
                else -> {}
            }
        }

        lifecycleOwner.lifecycle.addObserver(observer)

        onDispose {
            lifecycleOwner.lifecycle.removeObserver(observer)
        }
    }

    Text("Lifecycle aware content")
}

When to Use DisposableEffect vs LaunchedEffect

LaunchedEffectDisposableEffect
Use forOne-time actions, API calls, delaysRegister/unregister patterns
CleanupCoroutine auto-cancelsYou write onDispose { }
CoroutineYes (suspend functions)No (regular code)
ExampleLoad data, show snackbarListeners, observers, callbacks

rememberCoroutineScope — For Event-Based Actions

LaunchedEffect runs automatically based on keys. But sometimes you need to launch a coroutine from a button click or other user event. That is what rememberCoroutineScope is for.

@Composable
fun ScrollableScreen() {
    val scope = rememberCoroutineScope()
    val listState = rememberLazyListState()

    Column {
        // Button that scrolls to top when clicked
        Button(onClick = {
            // Launch a coroutine from a click event
            scope.launch {
                listState.animateScrollToItem(0)
            }
        }) {
            Text("Scroll to Top")
        }

        LazyColumn(state = listState) {
            items(100) { index ->
                Text(
                    "Item $index",
                    modifier = Modifier.padding(16.dp)
                )
            }
        }
    }
}

When to Use rememberCoroutineScope vs LaunchedEffect

rememberCoroutineScopeLaunchedEffect
Triggered byUser action (click, swipe)Composition or key change
When it runsWhen you call scope.launch { }Automatically
Use forButton clicks, user-driven eventsScreen load, data fetching

Rule: If the code runs because the user DID something → rememberCoroutineScope. If the code runs because something CHANGED → LaunchedEffect.

derivedStateOf — Computed Values

derivedStateOf creates a value that is computed from other state values. It only recalculates when the inputs change. This avoids unnecessary recompositions.

@Composable
fun FilteredList() {
    var searchQuery by remember { mutableStateOf("") }
    val allItems = remember {
        listOf("Apple", "Banana", "Cherry", "Date", "Elderberry", "Fig", "Grape")
    }

    // Note: derivedStateOf is ideal when output changes LESS frequently
    // than input (e.g., scroll offset → boolean). For simple filtering
    // where output changes on every keystroke, remember(query) { } works too.
    val filteredItems by remember {
        derivedStateOf {
            if (searchQuery.isEmpty()) allItems
            else allItems.filter {
                it.contains(searchQuery, ignoreCase = true)
            }
        }
    }

    Column(modifier = Modifier.padding(16.dp)) {
        OutlinedTextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("Search") },
            modifier = Modifier.fillMaxWidth()
        )

        Spacer(modifier = Modifier.height(8.dp))

        // Show "scroll to top" only when scrolled down
        Text("${filteredItems.size} results")

        LazyColumn {
            items(filteredItems) { item ->
                Text(item, modifier = Modifier.padding(vertical = 8.dp))
            }
        }
    }
}

When to Use derivedStateOf

Use it when:

  • You have an expensive calculation based on other state
  • The calculation should only run when its inputs change
  • You want to avoid unnecessary recompositions

Don’t use it for:

  • Simple transformations (just do it inline)
  • State that changes rarely
// DON'T — overkill for a simple check
val isEmpty by remember { derivedStateOf { list.isEmpty() } }

// DO — use derivedStateOf when the computation is expensive
val sortedAndFiltered by remember {
    derivedStateOf {
        items.filter { it.isActive }
             .sortedBy { it.name }
             .groupBy { it.category }
    }
}

SideEffect — Run on Every Recomposition

SideEffect runs after every successful recomposition. Use it rarely — only when you need to sync Compose state with non-Compose code.

@Composable
fun AnalyticsTracker(screenName: String) {
    // Runs after every recomposition
    SideEffect {
        analytics.setCurrentScreen(screenName)
    }

    // Screen content
}

Warning: This runs on EVERY recomposition. Don’t put API calls or heavy work here. It is only for syncing lightweight state.

Practical Example: Build a Timer with Compose Side Effects

Let’s build a screen that combines multiple side effects:

@Composable
fun TimerScreen() {
    var seconds by remember { mutableStateOf(0) }
    var isRunning by remember { mutableStateOf(false) }
    val scope = rememberCoroutineScope()

    // Timer logic — runs when isRunning changes
    LaunchedEffect(isRunning) {
        if (isRunning) {
            // isActive ensures the loop stops immediately when cancelled
            while (isActive) {
                delay(1000)
                seconds++
            }
        }
        // When isRunning becomes false, LaunchedEffect reruns with new key
        // The old coroutine is cancelled automatically
    }

    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(24.dp),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        // Display time
        val minutes = seconds / 60
        val remainingSeconds = seconds % 60
        Text(
            text = "%02d:%02d".format(minutes, remainingSeconds),
            fontSize = 64.sp,
            fontWeight = FontWeight.Bold,
            color = MaterialTheme.colorScheme.primary
        )

        Spacer(modifier = Modifier.height(32.dp))

        // Control buttons
        Row(horizontalArrangement = Arrangement.spacedBy(16.dp)) {
            // Start/Pause — user event, could use scope but onClick is fine
            Button(onClick = { isRunning = !isRunning }) {
                Text(if (isRunning) "Pause" else "Start")
            }

            // Reset
            OutlinedButton(
                onClick = {
                    isRunning = false
                    seconds = 0
                }
            ) {
                Text("Reset")
            }
        }

        Spacer(modifier = Modifier.height(16.dp))

        Text(
            text = if (isRunning) "Running..." else "Stopped",
            color = MaterialTheme.colorScheme.onSurfaceVariant
        )
    }
}

This timer uses:

  • LaunchedEffect(isRunning) — starts/stops the timer when isRunning changes
  • State (seconds, isRunning) — tracks the current time and running status
  • Automatic cancellation — when isRunning becomes false, LaunchedEffect reruns and the old while(true) loop is cancelled

Decision Table: Which Side Effect to Use?

SituationUse This
Load data when screen opensLaunchedEffect(Unit)
Reload data when ID changesLaunchedEffect(id)
Show snackbar on errorLaunchedEffect(error)
Register/unregister listenerDisposableEffect + onDispose
Observe lifecycle eventsDisposableEffect(lifecycleOwner)
Launch coroutine from button clickrememberCoroutineScope
Expensive filtered/sorted listderivedStateOf
Sync with external system on every recompositionSideEffect

Common Mistakes

Mistake 1: API Call Without LaunchedEffect

// BAD — runs on every recomposition (could be 10x per second)
@Composable
fun UserScreen(userId: String) {
    val user = api.getUser(userId) // This is not a suspend function call
}

// GOOD — runs once, reruns only when userId changes
@Composable
fun UserScreen(userId: String) {
    var user by remember { mutableStateOf<User?>(null) }
    LaunchedEffect(userId) {
        user = api.getUser(userId)
    }
}

Mistake 2: Forgetting onDispose

// BAD — listener is never removed, causes memory leak
DisposableEffect(Unit) {
    val listener = SomeListener()
    manager.addListener(listener)
    // Missing onDispose!
}

// GOOD — always clean up
DisposableEffect(Unit) {
    val listener = SomeListener()
    manager.addListener(listener)
    onDispose {
        manager.removeListener(listener)
    }
}

Mistake 3: Using rememberCoroutineScope for Data Loading

// BAD — launches a new coroutine on every recomposition if placed wrong
val scope = rememberCoroutineScope()
scope.launch { loadData() } // Wrong! This is not inside a click handler

// GOOD — use LaunchedEffect for automatic data loading
LaunchedEffect(Unit) {
    loadData()
}

Mistake 4: Wrong Key in LaunchedEffect

// BAD — reruns on every recomposition because a new list is created each time
LaunchedEffect(listOf(1, 2, 3)) {
    loadData()
}

// GOOD — use a stable key
LaunchedEffect(Unit) {
    loadData()
}

Quick Reference

APIRuns WhenCoroutineCleanupUse For
LaunchedEffect(key)Key changesYesAuto-cancelData loading, delays
DisposableEffect(key)Key changesNoonDispose { }Listeners, observers
rememberCoroutineScopeManual .launchYesAuto-cancelButton clicks, events
derivedStateOfInputs changeNoNoneExpensive computations
SideEffectEvery recompositionNoNoneSync with external code

Result

Here is what the timer app looks like when you run the code from this tutorial:

Light ModeDark Mode
Tutorial 11 LightTutorial 11 Dark

Source Code

The complete working code for this tutorial is on GitHub:

View source code on GitHub →

What’s Next?

In the next tutorial, we will learn about Retrofit with Compose — making real API calls, handling loading and error states, and displaying data from the internet in your app.

See you there.