LaunchedEffect

Every composable function we've written so far has been a pure description of UI for a given state — no network calls, no database reads, nothing that "just happens" on its own. But real screens need to kick off work: RecipeBox's recipe list has to actually load its recipes from somewhere the first time the screen appears. That work is asynchronous (a suspend function), and composables themselves are not suspend functions — you can't just call viewModel.loadRecipes() directly in the body of a composable and await it. This is the first of a small family of APIs, collectively called side effects, that exist purely to bridge that gap: LaunchedEffect is the one you'll reach for constantly, because it's built specifically for launching coroutines tied to a composable's presence on screen.

Why This Needs Its Own API

Recomposition can happen many times a second — every keystroke in a search box, every animation frame, every state change can trigger it. If loading the recipe list were a plain function call sitting in the body of RecipeListScreen, it would re-run on every single recomposition, firing off a fresh database query dozens of times for no reason. LaunchedEffect solves this by launching a coroutine that survives recomposition — it starts once, keeps running across recompositions, and only restarts (or stops) under conditions you control.

Basic Syntax: A Coroutine Tied to the Composable's Lifecycle

LaunchedEffect takes one or more key arguments and a suspend lambda body. The moment the composable holding it enters composition, the lambda starts running inside a coroutine. The moment the composable leaves composition (the user navigates away, or an if stops showing it), that coroutine is automatically cancelled — you never have to remember to clean it up yourself.

@Composable
fun RecipeListScreen(viewModel: RecipeListViewModel) {
    val recipes by viewModel.recipes.collectAsState()

    LaunchedEffect(Unit) {
        // This is a coroutine — we can call suspend functions directly here
        viewModel.loadRecipes()
    }

    LazyColumn {
        items(recipes) { recipe ->
            RecipeRow(recipe = recipe)
        }
    }
}

The key Parameter: Controlling WHEN It Re-Runs

The key (or keys) passed to LaunchedEffect are what Compose compares across recompositions to decide whether the existing coroutine should keep running, or be cancelled and replaced with a brand-new one:

Key passedBehavior
Unit (or any constant)Never changes across recompositions, so the coroutine launches exactly once and keeps running until the composable leaves composition — perfect for a one-shot "load on screen entry" effect.
A variable, e.g. searchQueryEvery time searchQuery changes to a new value, Compose cancels the currently running coroutine and immediately launches a fresh one with the new value in scope.
Multiple keys, e.g. (recipeId, category)Restarts whenever any of the listed keys changes.
Important: "Cancels the old coroutine first" is not just bookkeeping — it means any in-flight work from the previous key (like a database query for a stale search term) is stopped before the new one starts, so an old, slow response can never race ahead of a newer one and overwrite it on screen.

Worked Example 1: Load RecipeBox's Recipe List Once

The most common use of LaunchedEffect is a one-time load the instant a screen first appears — passing Unit as the key guarantees it only ever runs once for that screen's lifetime:

@Composable
fun RecipeListScreen(viewModel: RecipeListViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsState()

    // Unit never changes, so loadRecipes() runs exactly once per time
    // this screen enters composition — not on every recomposition
    LaunchedEffect(Unit) {
        viewModel.loadRecipes()
    }

    when {
        uiState.isLoading -> LoadingSpinner()
        else -> RecipeList(recipes = uiState.recipes)
    }
}

Worked Example 2: Re-Run a Search When the Query Changes

RecipeBox's search screen needs the opposite behavior — reload results every time the user's search text changes. Passing searchQuery as the key makes that automatic:

@Composable
fun SearchScreen(viewModel: SearchViewModel = viewModel()) {
    var searchQuery by remember { mutableStateOf("") }
    val results by viewModel.searchResults.collectAsState()

    // Re-runs every time searchQuery takes on a new value.
    // Compose cancels any still-running search for the PREVIOUS query first,
    // so a slow response for "chi" can never overwrite fresher results for "chicken".
    LaunchedEffect(searchQuery) {
        if (searchQuery.length >= 2) {
            viewModel.searchRecipes(searchQuery)
        }
    }

    Column {
        OutlinedTextField(
            value = searchQuery,
            onValueChange = { searchQuery = it },
            label = { Text("Search recipes") }
        )
        LazyColumn {
            items(results) { recipe -> RecipeRow(recipe = recipe) }
        }
    }
}

Every keystroke updates searchQuery, which triggers a normal recomposition — but the LaunchedEffect block only actually restarts its coroutine because the key changed, not because a recomposition happened. That distinction is exactly why the key parameter exists.

Next up: LaunchedEffect is built for suspend work that finishes or gets cancelled on its own. Some effects — like registering a listener — need an explicit cleanup step when they end. That's DisposableEffect.