What are CompositionLocals and when should you avoid them?
Understand when a value belongs to the surrounding UI and when hiding a dependency makes Compose code harder to understand.
What the interviewer is testing
The interviewer is really testing whether you understand when hiding a dependency is worth the trade-off.
It is easy to learn CompositionLocal as a way to avoid passing the same value through several composables. The harder judgement is knowing whether that value genuinely belongs to the surrounding UI, or whether those parameters are useful because they show what the component needs.
A strong answer makes that distinction before reaching for the API.
❌ Common mistake
If they ask you to elaborate
Imagine an app has its own design system and every screen uses the same spacing scale:
data class AppSpacing(
val small: Dp,
val medium: Dp,
val large: Dp,
)An OrderCard deep in the screen needs medium. The screen above it might not care about spacing at all, and neither might the section between them.
The value could be passed all the way down:
App
↓ spacing
OrdersScreen
↓ spacing
OrdersSection
↓ spacing
OrderCardDuring a code review, the useful question is not whether those parameters look repetitive. It is: does OrdersScreen actually need this spacing value, or is it only carrying the value because a child happens to use the same design system as the rest of the app?
That is where CompositionLocal starts to make sense.
The theme can provide the spacing once:
val LocalAppSpacing = staticCompositionLocalOf {
AppSpacing(
small = 4.dp,
medium = 8.dp,
large = 16.dp,
)
}
@Composable
fun AppTheme(content: @Composable () -> Unit) {
CompositionLocalProvider(
LocalAppSpacing provides AppSpacing(
small = 4.dp,
medium = 8.dp,
large = 16.dp,
),
content = content,
)
}Then OrderCard can read the spacing that applies where it is currently rendered:
@Composable
fun OrderCard() {
val spacing = LocalAppSpacing.current
Column(
verticalArrangement = Arrangement.spacedBy(spacing.medium),
) {
// ...
}
}The important part is what changed.
The code did not hide something that belongs specifically to OrdersScreen. It moved design-system context to the place that owns it: the surrounding UI.
This is also why a CompositionLocal is better thought of as scoped to part of the UI tree, rather than simply global.
Suppose one part of the app needs a denser layout:
CompositionLocalProvider(
LocalAppSpacing provides compactSpacing,
) {
CompactOrderPanel()
}Everything inside that provider sees compactSpacing. Code outside it still sees the value provided higher up. If providers are nested, a composable gets the nearest value for the part of the UI where it is rendered.
That is the same shape as values Compose already treats as part of the surrounding environment: theme values, density, layout direction, content color, or Android Context. A descendant may need them, but the composables in between should not need extra parameters purely to carry them down the tree.
Now compare that with a retry action:
CheckoutScreen(
onRetry = viewModel::retry,
)Maybe onRetry has to travel through ErrorSection before it reaches RetryButton. Creating LocalOnRetry would make that parameter disappear, but the retry action is not part of the environment around the button. It is part of how the checkout feature behaves.
Keeping the callback as a parameter means RetryButton still shows what it needs. It is easier to preview, reuse, test, and understand without first finding some provider elsewhere in the tree.
That is why "it avoids parameter drilling" is not enough of a reason on its own.
A follow-up may ask about compositionLocalOf and staticCompositionLocalOf.
With compositionLocalOf, Compose tracks where the value is read, so when the provided value changes it can update the places that read it. staticCompositionLocalOf does not track those individual reads; if its value changes, the content under the provider is recomposed instead. The static version therefore fits values that effectively stay fixed, which is common for design-system constants.
That distinction is useful to know, but it comes after the more important decision: should this value be hidden from the composable's parameters in the first place?
Production thinking
A good way to decide whether a CompositionLocal belongs in an app is to start with something that genuinely needs to travel through a large part of the UI.
Imagine the app records analytics when important actions happen.
At first, passing an analytics object directly looks completely reasonable:
@Composable
fun HomeScreen(
analytics: Analytics
) {
Feed(
analytics = analytics
)
}Then Feed passes it to ArticleCard, which passes it to an action row, which finally uses it when the user taps Share.
The middle composables do not care about analytics. They are only carrying it because something further down needs it:
HomeScreen
↓ analytics
Feed
↓ analytics
ArticleCard
↓ analytics
ArticleActions
↓ uses analyticsOnce that pattern appears across a large UI tree, a CompositionLocal can remove plumbing that is not helping those APIs describe their actual job.
The app can provide the dependency near the part of the tree where it applies:
CompositionLocalProvider(
LocalAnalytics provides analytics
) {
AppContent()
}and a descendant that genuinely needs it can read it:
val analytics = LocalAnalytics.currentThat is the useful side of the API. The value belongs to a broad part of the UI tree, many layers may sit between the provider and the consumer, and those middle layers should not need parameters for something they never use.
Now let the app grow.
A feature needs a repository:
val repository = LocalRepository.currentThen another screen needs its ViewModel:
val viewModel = LocalProfileViewModel.currentThen mutable feature state follows:
val checkoutState = LocalCheckoutState.currentThe code may look convenient because parameter lists get shorter, but something important has changed.
Open this composable:
@Composable
fun CheckoutSummary() {
// ...
}Its function signature no longer tells the reader what it depends on. To understand the screen, someone has to read the body and know which values happen to be available higher in the Composition.
That becomes painful when the composable is reused somewhere else. It also makes previews and tests harder to understand because the missing dependency appears only when the code tries to read the CompositionLocal.
For feature dependencies, direct parameters usually tell a clearer story:
@Composable
fun CheckoutSummary(
state: CheckoutUiState,
onPay: () -> Unit
) {
// ...
}Now the boundary is visible immediately. The composable says what it needs, and the caller decides where those values come from.
This does not mean every CompositionLocal is suspicious. Compose itself uses them for things such as theme values, density, layout direction, and other context that naturally applies to a whole subtree.
The useful question is:
Does this value describe the environment this UI is running in, or is it just something this feature needs?
Theme, localization-like context, design-system values, or another dependency that genuinely applies across a large subtree can fit naturally.
A repository needed by one feature, a screen's mutable state, or a callback that could simply be a parameter usually does not become better architecture by being hidden in the tree.
There is also a useful test when introducing one:
Imagine moving the composable into a different part of the app.
If its parameters tell enough of the story and the surrounding UI environment can reasonably provide the rest, the CompositionLocal is probably serving its intended role.
If moving it means discovering a trail of hidden feature dependencies one runtime failure at a time, too much has been placed there.
The goal is not to avoid parameter passing.
It is to avoid meaningless parameter passing through layers that do not care about the value, without hiding the dependencies that make a feature understandable.
Follow-up questions
What separates a senior answer
Key takeaways
Learn more
What is the Compose snapshot system?
Understand how Compose tracks state reads and changes while keeping snapshot-backed state consistent.
What are mutableStateListOf and mutableStateMapOf?
Understand when Compose can see changes inside a mutable collection and when replacing an immutable collection is clearer.