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.

Honest framing: This is an advanced, intentionally narrow tool. Implicit dependencies are harder to trace than explicit ones — reach for 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:

FunctionBehavior 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.

Next up: CompositionLocal passes data down implicitly. Slot APIs covers the opposite, explicit tool for reuse — composables that accept other composables as parameters.