CompositionLocal is not dependency injection, and using it that way hurts later
CompositionLocal passes values down the composition without parameters. That invisibility is the feature and the danger: three tests for when it is the right tool, and what to use instead when it isn't.

Day 15 of 100. Day 13 called prop drilling a problem and offered two fixes — group the parameters, or use slots. There's a third, and it's the one that gets reached for first and regretted most.
The symptom
A theme value needed forty layers down:
@Composable fun App(theme: Theme) = Screen(theme)
@Composable fun Screen(theme: Theme) = Section(theme)
@Composable fun Section(theme: Theme) = Row(theme)
@Composable fun Row(theme: Theme) = Label(theme)
@Composable fun Label(theme: Theme) = Text("hi", color = theme.textColor)
Four composables carry a parameter they never read. Day 13's answer — group or slot — doesn't fit here, because every leaf might want the theme and it's genuinely ambient.
CompositionLocal is the tool for exactly this:
val LocalTheme = compositionLocalOf { LightTheme }
@Composable fun App() {
CompositionLocalProvider(LocalTheme provides darkTheme) {
Screen() // no parameter
}
}
@Composable fun Label() {
Text("hi", color = LocalTheme.current.textColor) // reads from anywhere below
}
Four signatures cleaned up. Which is why the next step is usually to do it to everything.

Why the obvious extension fails
The obvious extension is to pass the repository, the ViewModel, the navigation controller and the analytics client the same way:
val LocalRepo = compositionLocalOf<UserRepo> { error("not provided") }
@Composable fun UserCard() {
val user = LocalRepo.current.getUser() // dependency injection!
}
It compiles, it removes parameters, and it costs you three things at once.
The dependency is invisible. UserCard() takes nothing and needs a repository. You
find that out by reading the body, or by the crash at runtime.
Failures move to runtime. compositionLocalOf { error("not provided") } is a
promise checked when the composable runs, not when it's written. Forget the provider on
one navigation path and that screen crashes — in QA if you're lucky.
Reuse and preview break. A @Preview for UserCard now needs a
CompositionLocalProvider wrapper with a fake repo. Every test does too. The component
that looked decoupled is coupled to an ambient it never declares.
Parameters are ugly and honest. Ambients are pretty and silent, and silence is the expensive property when the codebase grows.
The actual mechanism
A CompositionLocal is a value keyed to a subtree of the composition. provides
binds it for everything below; .current reads the nearest binding above. It's dynamic
scoping — the value depends on where you're called from, not where you're defined.
Two constructors, and picking the wrong one is a real performance bug:
val LocalTheme = compositionLocalOf { LightTheme } // tracks reads, recomposes readers
val LocalDensity = staticCompositionLocalOf { Density(1f) } // no tracking, recomposes whole subtree
compositionLocalOf participates in the snapshot system: change the provided value and
only the composables that actually read it recompose. That tracking costs a little at
every read.
staticCompositionLocalOf skips the tracking entirely — reads are as cheap as a field
access — but Compose no longer knows who read it, so changing the value recomposes the
entire subtree under the provider.
The rule follows directly: static for values that essentially never change (density, a logger, a static configuration); non-static for anything that does (theme with a runtime dark-mode toggle, a user session).
Get this backwards — staticCompositionLocalOf for a theme the user can toggle — and
every toggle recomposes the whole app. It'll look like "Compose is slow at theming".
The three tests
Before creating one, all three should be yes:
- Is it genuinely ambient? Does most of the subtree plausibly want it? Themes, density, locale, a text style. Not "this one screen's ViewModel".
- Does it have a sensible default? If the answer to a missing provider is
error("not provided"), you're modelling a required dependency as an optional ambient. Parameters express required better. - Would a caller ever want to override it for a subtree? This is the property that
makes it worth the invisibility —
CompositionLocalProvider(LocalContentColor provides Red) { … }is a real, useful thing to do. If nobody would ever override it, there's no reason for the value to travel implicitly.
The set that passes all three is small, and it's roughly what the Compose libraries
already provide: LocalContext, LocalDensity, LocalLifecycleOwner,
LocalContentColor, LocalTextStyle, LocalLayoutDirection. That's not a coincidence
— it's the natural size of the category.
What to use instead
For dependencies: constructor injection at the screen boundary. Pass the ViewModel
into the screen composable (or resolve it with viewModel()), then pass plain data
down. Components stay previewable, and the dependency graph stays visible.
For navigation: hoist a callback, don't provide a NavController.
onUserClick: (String) -> Unit can be previewed with {} and tested with a lambda; a
NavController from an ambient can be neither.
For screen data: parameters, grouped into a state object if there are many. That's Day 13, and it remains the answer for almost everything.
There's also a middle path worth knowing, because it keeps coming up in design-system work: provide the ambient, but expose a parameter that defaults to it.
@Composable
fun Label(
text: String,
style: TextStyle = LocalTextStyle.current, // ambient by default, overridable
) = Text(text, style = style)
Callers get the ergonomics of the ambient and the honesty of a parameter. Previews and
tests pass a literal; production passes nothing. Every Material component is built this
way — contentColor, textStyle, shape all default to an ambient and all accept an
override — and copying that shape is a reliable way to get the benefit without the
invisibility.
How to prove it
Two checks that catch both failure modes.
The preview test. Write a @Preview for a deep component. If it needs a
CompositionLocalProvider for anything other than a theme, an ambient is doing a
parameter's job.
The recomposition test. Toggle the provided value and watch Layout Inspector's
counts. With compositionLocalOf, only readers should increment. If the whole subtree
increments, you used staticCompositionLocalOf for a value that changes — a one-word
fix with a large effect.
Worth adding: a CompositionLocal provided above your NavHost is not re-provided
per destination, while one provided inside a destination is scoped to it. Nesting
providers is legitimate and occasionally the whole point — a settings screen that
previews a theme by providing it locally, for instance — but it means "where is this
value coming from" is a question with a non-obvious answer, which is the cost you signed
up for.
What this generalizes to
CompositionLocal is dynamic scoping, which computer science has repeatedly tried and
mostly retreated from — Lisp's special variables, thread-locals, React's Context. The
pattern keeps reappearing because sometimes a value really is ambient, and it keeps
being regretted because implicit data flow is hard to trace.
The stable conclusion across all of them: use it for the environment, not for dependencies. Theme and locale are the environment. Your repository is a dependency, and dependencies belong in signatures.
Tomorrow, Day 16: architectural layering — the UI, state-holder and data layers, and which of them each thing you've learned this week actually belongs to.
Day 15 of a 100-day series on Jetpack Compose, working through the official documentation in order. Source: Locally scoped data with CompositionLocal.