CompositionLocal
Every composable you've written so far gets its data through explicit parameters — you call
RecipeCard(title = recipe.title) and the value flows straight in. That's almost
always the right approach: explicit parameters are easy to trace, easy to test, and make a
composable's dependencies obvious just by reading its signature. CompositionLocal is
the escape hatch for the rare case where threading a value through every single level of
a deep tree — just so three levels down one leaf composable can read it — would add more noise
than it's worth. You've already been using one without knowing it: MaterialTheme.colorScheme
and MaterialTheme.typography are backed by CompositionLocals under the
hood, which is exactly why every composable can read the current theme without you passing a
ColorScheme parameter into everything you write.
CompositionLocal sparingly, and prefer a plain parameter or a hoisted state object whenever that's practical.Declaring a CompositionLocal
You create one at the top level with either compositionLocalOf {} or
staticCompositionLocalOf {}, giving it a default value used whenever nothing further
up the tree has provided one:
LocalRecipeBoxSpacing.kt — a spacing scale available anywhere in the tree
data class RecipeBoxSpacing(
val small: Dp = 4.dp,
val medium: Dp = 12.dp,
val large: Dp = 24.dp
)
val LocalRecipeBoxSpacing = staticCompositionLocalOf { RecipeBoxSpacing() }
compositionLocalOf vs. staticCompositionLocalOf
Both give you an implicit value readable anywhere below where it's provided. They differ only in how a change to that value triggers recomposition:
| Function | Behavior when the value changes |
|---|---|
compositionLocalOf {} | Compose tracks reads individually — only the composables that actually read the local get recomposed. Costs a little more bookkeeping, but is precise. Use this for a value that changes at runtime, like the current recipe's category color. |
staticCompositionLocalOf {} | No per-read tracking at all — changing the value forces the entire content under the provider to recompose, with no fine-grained skipping. Cheaper when the value essentially never changes after being set, like a fixed spacing scale or a locale. |
RecipeBoxSpacing above is a good fit for staticCompositionLocalOf — a
spacing scale is set once at the top of the app and never changes while the app runs, so there's
no recomposition-tracking cost worth paying for it.
Providing a Value with CompositionLocalProvider
Declaring a CompositionLocal only gives it a default. To actually supply a value for
a subtree, wrap that subtree in CompositionLocalProvider, typically once near the
root of your app:
RecipeBoxApp.kt — providing spacing at the root
@Composable
fun RecipeBoxApp() {
CompositionLocalProvider(LocalRecipeBoxSpacing provides RecipeBoxSpacing()) {
MaterialTheme {
RecipeBoxNavHost()
}
}
}
Reading the Value Deep in the Tree
Any composable under that provider — no matter how deeply nested, and without a single spacing
parameter added to any function signature along the way — can now read
LocalRecipeBoxSpacing.current:
RecipeIngredientRow.kt — deep inside RecipeCard, several levels below the provider
@Composable
fun RecipeIngredientRow(ingredient: String) {
val spacing = LocalRecipeBoxSpacing.current
Row(
modifier = Modifier.padding(
horizontal = spacing.medium,
vertical = spacing.small
)
) {
Icon(Icons.Default.Check, contentDescription = null)
Spacer(modifier = Modifier.width(spacing.small))
Text(text = ingredient)
}
}
Notice that RecipeIngredientRow's signature never mentions spacing at all — every
call site further up (RecipeCard, the recipe list screen, and so on) is completely
unaware that spacing is being threaded through. That's the entire point, and also exactly why
this should be used sparingly: reading RecipeIngredientRow's signature alone doesn't
tell you it depends on LocalRecipeBoxSpacing — you have to know to look.
Overriding for a Sub-Tree
CompositionLocalProvider can be nested — a value provided again lower in the tree
shadows the outer one only for composables under that inner provider, which is useful for a
single screen that needs denser spacing without affecting the rest of the app:
CompositionLocalProvider(
LocalRecipeBoxSpacing provides RecipeBoxSpacing(small = 2.dp, medium = 6.dp, large = 16.dp)
) {
// Only this screen's subtree sees the denser scale
CompactRecipeListScreen()
}
When to Reach for a Parameter Instead
Before adding a new CompositionLocal, ask whether the value is truly cross-cutting
and set once (theme, spacing scale, layout direction) or whether it's specific data that belongs
to a particular screen or feature (the currently selected recipe, a ViewModel's state). The
latter should always be an explicit parameter or a hoisted state object passed down normally —
CompositionLocal is for ambient, app-wide concerns, not a shortcut around prop
drilling for ordinary feature data.