Sitemap

CompositionLocal in Jetpack Compose

7 min readJun 1, 2025

Introduction

Have you ever found yourself passing the same parameter through five layers of composable functions to use it in that deeply nested component? Or perhaps you’ve created a beautiful theme system, only to realize you need to thread those theme values through your entire component hierarchy? If these scenarios sound familiar, you’re not alone, and CompositionLocal might be the elegant solution you’ve been looking for.

In Jetpack Compose, data typically flows down through the UI tree as parameters to composable functions. This explicit approach makes dependencies transparent and traceable, which is generally beneficial. But as applications grow in complexity, this pattern can lead to what’s commonly known as “prop drilling” or “parameter pollution,” passing data through multiple intermediate components that don’t use it.

This is where CompositionLocal shines. It provides a mechanism for making data implicitly available to any composable within a specific composition scope, without requiring it to be passed explicitly through every layer. Think of it as creating invisible channels through your UI tree that carry specific pieces of information to exactly where they’re needed.

In this article, we’ll explore one of the most powerful yet often misunderstood benefits of CompositionLocal: its ability to manage component state that follows a natural hierarchy elegantly. We’ll examine how to apply this concept to fundamental components, using a practical example of a stacking surface component that automatically tracks its depth in the UI hierarchy.

The Theory and Core Concept of Composition Local

Understanding of Data Flow in Compose

Let’s briefly review how data typically flows in Jetpack Compose. In the standard approach, data is passed explicitly as parameters to composable functions.

@Composable
fun ParentComponent(data: MyData) {
ChildComponent(data = data)
}

@Composable
fun ChildComponent(data: MyData) {
GrandchildComponent(data = data)
}

@Composable
fun GrandchildComponent(data: MyData) {
// Finally use the data here
Text(text = data.value)
}

This explicit parameter passing has clear benefits:

  • Dependencies are visible and traceable
  • The compiler enforces type safety
  • Function signatures communicate requirements

However, this approach becomes cumbersome when:

  • Data needs to travel through many layers of components
  • Intermediate components don’t use the data themselves
  • Multiple pieces of related data need to be passed together

What is CompositionLocal?

CompositionLocal is a mechanism in Jetpack Compose that allows you to create tree-scoped objects that can be accessed implicitly by any composable within a specific composition scope. It’s designed to solve the “prop drilling” problem by creating implicit channels for data to flow through your UI tree.

Think of CompositionLocal as creating an invisible ambient environment around your compostables. Any compostable within this environment can access values from it without explicitly receiving them as parameters.

The Technical Mechanics

At its core, CompositionLocal works through a combination of three key components

  1. Creating a CompositionLocal instance that serves as a key for storing and retrieving a specific type of value
  2. Setting a value for that CompositionLocal at a specific point in the composition tree
  3. Reading the current value from anywhere within the scope where it was provided

Here’s how these components work together:

// 1. Declaration
val LocalMyData = compositionLocalOf<MyData> { error("No MyData provided") }

@Composable
fun MyApp() {
val myData = MyData("Hello, CompositionLocal!")

// 2. Provision
CompositionLocalProvider(LocalMyData provides myData) {
// All composables in this scope can access myData
SomeComponent()
}
}

@Composable
fun SomeComponent() {
// No need to receive myData as a parameter
SomeDeeplyNestedComponent()
}

@Composable
fun SomeDeeplyNestedComponent() {
// 3. Consumption
val myData = LocalMyData.current
Text(text = myData.value)
}

The Two Types of CompositionLocal

Jetpack Compose offers two different implementations of CompositionLocal, each optimized for different use cases:

1. compositionLocalOf

val LocalMyData = compositionLocalOf<MyData> { defaultValue }
  • Designed for values that may change during runtime
  • When the provided value changes, it triggers the recomposition of all composables that read it
  • Ideal for dynamic values like theme settings that might change based on user preferences

2. staticCompositionLocalOf

val LocalMyData = staticCompositionLocalOf<MyData> { defaultValue }
  • Optimized for values that rarely or never change during runtime
  • Changes to the provided value do not automatically trigger recomposition
  • More efficient for static values like typography definitions or spacing constants

Scoping and Value Resolution

One of the most powerful aspects of CompositionLocal is its scoping behavior. The value of a CompositionLocal is determined by the nearest provider up the composition tree:

CompositionLocalProvider(LocalColor provides Color.Red) {
// LocalColor.current is Red here

CompositionLocalProvider(LocalColor provides Color.Blue) {
// LocalColor.current is Blue here

// Nested components will use Blue unless overridden again
}

// Back to Red here
}

This scoping behavior allows for powerful patterns:

  • Global defaults at the application root
  • Section-specific overrides for different parts of your UI
  • Component-specific values for special cases

When to Use CompositionLocal

CompositionLocal is a powerful tool, but it’s not appropriate for every situation. Here are some guidelines for when to consider using it:

Good Use Cases:

  • UI theming (colors, typography, shapes)
  • Spacing and dimension systems
  • Accessibility settings
  • A layout configuration that follows the visual hierarchy
  • Cross-cutting UI concerns

Poor Use Cases:

  • Business logic or application state
  • Data that state management solutions should manage
  • Values that don’t naturally follow the UI tree structure

The key question to ask is: “Does this data naturally follow the visual hierarchy of my UI?” If the answer is yes, CompositionLocal may be a suitable option. If not, consider other state management approaches.

Case Study: The StackSurface Component

Now that we understand the theory behind CompositionLocal, let’s examine a practical example that demonstrates its power. The user has provided a StackSurface component that elegantly uses CompositionLocal to track its depth in the UI hierarchy. Let’s break it down piece by piece.

The Code

private val LocalStackDepth = compositionLocalOf { 0 }

@Composable
fun StackSurface(
modifier: Modifier = Modifier,
baseColor: Color = Color(0xffc10505),
elevationStep: Dp = 3.dp,
luminanceStep: Float = 0.15f,
content: @Composable () -> Unit
) {
val level = LocalStackDepth.current
val theme = LocalTheme.current
val background = baseColor.shiftLuminance(1f - luminanceStep * level)
val elevation = elevationStep * level

Box(
modifier
.shadow(elevation, theme.shapeMedium, clip = false)
.clip(theme.shapeMedium)
.background(background)
) {
// Make everything inside one level deeper
CompositionLocalProvider(
LocalStackDepth provides level + 1,
LocalContentColor provides background.contentColor()
) {
content()
}
}
}

private fun Color.shiftLuminance(factor: Float): Color {
val f = max(0f, factor)
return Color(
red * f.coerceIn(0f, 1f),
green * f.coerceIn(0f, 1f),
blue * f.coerceIn(0f, 1f),
alpha
)
}
   StackSurface(
modifier = Modifier.fillMaxSize().padding(16.dp),
elevationStep = 3.dp,
luminanceStep = 0.2f
) {
StackSurface( modifier = Modifier.fillMaxSize().padding(16.dp),) {
StackSurface( modifier = Modifier.fillMaxSize().padding(16.dp),){
StackSurface( modifier = Modifier.fillMaxSize().padding(16.dp),){
StackSurface( modifier = Modifier.fillMaxSize().padding(16.dp),){

}
}
}
}
}
}

Why This Approach Shines

Let’s consider the alternative implementation without CompositionLocal:

@Composable
fun StackSurface(
modifier: Modifier = Modifier,
baseColor: Color = Color(0xffc10505),
elevationStep: Dp = 3.dp,
luminanceStep: Float = 0.15f,
level: Int = 0, // Explicit level parameter
content: @Composable (Int) -> Unit // Content now receives the level
) {
val theme = LocalTheme.current
val background = baseColor.shiftLuminance(1f - luminanceStep * level)
val elevation = elevationStep * level

Box(
modifier
.shadow(elevation, theme.shapeMedium, clip = false)
.clip(theme.shapeMedium)
.background(background)
) {
content(level + 1) // Pass the incremented level to content
}
}

With this approach, you’d need to use it like this:

StackSurface { nextLevel ->
Text("Level 0")
StackSurface(level = nextLevel) { nextNextLevel ->
Text("Level 1")
StackSurface(level = nextNextLevel) { _ ->
Text("Level 2")
}
}
}

The problems with this approach are:

  1. Every component that might contain a StackSurface needs to accept and forward the level parameter.
  2. The content lambda now requires a parameter, making the API more complex and less intuitive.
  3. If you forget to pass the level correctly at any point, the visual hierarchy breaks.
  4. It’s harder to extract parts of the UI into separate composables while maintaining the correct level tracking.

By using CompositionLocal, the StackSurface component achieves a much more elegant, composable, and maintainable design.

Conclusion

CompositionLocal is a cool feature in Jetpack Compose that enables elegant solutions to common UI development challenges. By allowing data to flow implicitly through the composition tree, we can create more maintainable, composable, and context-aware components.

The StackSurface example we explored demonstrates how CompositionLocal can elegantly solve the problem of tracking component depth in a UI hierarchy. By using CompositionLocal to propagate and increment the depth value, we created a component that automatically adapts its appearance based on its position in the stack, without complicating its API or breaking composability.

We also explored several other real-world applications of CompositionLocal, including navigation systems, form fields, and layout coordination. These examples demonstrate how the same pattern can be applied to a wide range of UI challenges where data naturally aligns with the visual hierarchy.

When used appropriately, CompositionLocal can significantly improve the design and maintainability of your Compose UI. However, it’s important to remember that it’s not a universal solution for all state management problems. Use it for UI-related values that naturally follow the composition tree, and rely on other state management approaches for application logic and data that doesn’t fit this pattern.

By adding CompositionLocal to your toolkit and following the best practices we’ve discussed, you’ll be able to create more elegant, composable, and maintainable UI components in your Jetpack Compose applications.

--

--

No responses yet