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 passed | Behavior |
|---|---|
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. searchQuery | Every 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. |
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.
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.