When building apps with Jetpack Compose, you’ll often pass data down through multiple layers of composables. At first, this feels fine. But as your UI grows, you may find yourself passing the same parameter through five or six functions just to reach a deeply nested child.
That pattern is called prop drilling.
It works, but it clutters your APIs and makes your code harder to maintain.
This is where CompositionLocal in Jetpack Compose becomes incredibly useful. In this guide, we’ll learn what it is, when to use it, how it works under the hood, and how to avoid common mistakes.
What Is Prop Drilling in Jetpack Compose?
Prop drilling happens when you pass data through multiple composables, even though intermediate composables don’t use that data.
For example:
@Composable
fun ParentScreen() {
val userName = "Amol"
LevelOne(userName)
}
@Composable
fun LevelOne(userName: String) {
LevelTwo(userName)
}
@Composable
fun LevelTwo(userName: String) {
Greeting(userName)
}
@Composable
fun Greeting(userName: String) {
Text(text = "Hello, $userName")
}Only Greeting actually needs userName. But we pass it through LevelOne and LevelTwo anyway.
In small apps, this is fine. In large apps, it becomes noisy and harder to refactor.
What Is CompositionLocal in Jetpack Compose?
CompositionLocal in Jetpack Compose is a way to implicitly pass data down the composable tree without manually threading it through every function.
It allows you to define a value once and access it anywhere inside a specific part of the composition.
Think of it as a scoped global value. It’s not truly global, but it’s available to any composable inside its scope.
Jetpack Compose already uses it internally. For example:
MaterialThemeLocalContextLocalDensityLocalLayoutDirection
These are all built using CompositionLocal.
When Should You Use CompositionLocal?
Use CompositionLocal in Jetpack Compose when:
- The data is cross-cutting (theme, configuration, permissions, localization).
- Many composables need access to it.
- Passing it as a parameter would create unnecessary noise.
Avoid using it for:
- Screen-specific business logic
- Frequently changing state
- ViewModel data that belongs to a specific screen
In short, use it for shared environmental values, not regular state.
How to Create a CompositionLocal
There are two main ways to create one:
compositionLocalOfstaticCompositionLocalOf
Let’s start with the common one.
Example: Creating a CompositionLocal
Suppose we want to provide a custom app theme color.
Define the CompositionLocal
val LocalAppPrimaryColor = compositionLocalOf { Color.Blue }What this does:
- Creates a CompositionLocal.
- Provides a default value (
Color.Blue). - If no value is provided, the default will be used.
Provide a Value
We use CompositionLocalProvider to supply a value.
@Composable
fun MyApp() {
CompositionLocalProvider(
LocalAppPrimaryColor provides Color.Green
) {
HomeScreen()
}
}Here,
- Inside
MyApp, we provideColor.Green. - Any composable inside
HomeScreen()can now access it. - Outside this block, the default value applies.
So basically,
What’s the provides keyword?
It’s an infix function that creates a ProvidedValue pairing your CompositionLocal with an actual value. Think of it as saying: “For this scope, LocalAppPrimaryColor provides Color.Green.”
You can even provide multiple values at once:
@Composable
fun MyApp() {
val theme = AppTheme(/* ... */)
val user = User(id = "123", name = "Anaya")
CompositionLocalProvider(
LocalAppTheme provides theme,
LocalUser provides user
) {
MainScreen()
}
}Consume the Value
Now we access it using .current.
@Composable
fun HomeScreen() {
val primaryColor = LocalAppPrimaryColor.current
Text(
text = "Welcome",
color = primaryColor
)
}That’s it.
No parameter passing.
No prop drilling.
How CompositionLocal in Jetpack Compose Works Internally
Understanding this improves your architectural decisions.
When you use CompositionLocal in Jetpack Compose, the value becomes part of the composition tree. Compose tracks reads of .current. If the value changes, only the composables that read it will recompose.
This makes it efficient.
It’s not like a global variable. It’s scoped and lifecycle-aware.
Using staticCompositionLocalOf
Use this when the value rarely or never changes. It’s more optimized but less flexible:
val LocalAppConfiguration = staticCompositionLocalOf {
AppConfiguration(apiUrl = "https://api.softaai.com")
}When to use static? Only when the value is truly static for the entire composition, like build configuration or app constants.
compositionLocalOf Vs staticCompositionLocalOf
This is important.
compositionLocalOf
- Tracks reads.
- Causes recomposition when value changes.
- Best for values that may change.
Example: dynamic theme.
staticCompositionLocalOf
- Does NOT track reads.
- Better performance.
- Use when value will never change.
Example: app configuration object that stays constant.
Example:
val LocalAppConfig = staticCompositionLocalOf<AppConfig> {
error("No AppConfig provided")
}Use this only when you are sure the value won’t change.
Real-World Example: Building a Theme System
Let’s build a complete example that shows the power of CompositionLocal in Jetpack Compose. We’ll create a theme system with light and dark modes:
Define a data class
// Step 1: Define our theme data
data class AppTheme(
val colors: AppColors,
val typography: AppTypography,
val isDark: Boolean
)
data class AppColors(
val primary: Color,
val background: Color,
val surface: Color,
val text: Color
)
data class AppTypography(
val heading: TextStyle,
val body: TextStyle
)Create CompositionLocal
// Step 2: Create CompositionLocal
val LocalAppTheme = compositionLocalOf {
AppTheme(
colors = AppColors(
primary = Color.Blue,
background = Color.White,
surface = Color.LightGray,
text = Color.Black
),
typography = AppTypography(
heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
body = TextStyle(fontSize = 16.sp)
),
isDark = false
)
}Provide the value
// Step 3: Create theme instances
object AppThemes {
val Light = AppTheme(
colors = AppColors(
primary = Color(0xFF2196F3),
background = Color.White,
surface = Color(0xFFF5F5F5),
text = Color.Black
),
typography = AppTypography(
heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
body = TextStyle(fontSize = 16.sp)
),
isDark = false
)
val Dark = AppTheme(
colors = AppColors(
primary = Color(0xFF90CAF9),
background = Color(0xFF121212),
surface = Color(0xFF1E1E1E),
text = Color.White
),
typography = AppTypography(
heading = TextStyle(fontSize = 24.sp, fontWeight = FontWeight.Bold),
body = TextStyle(fontSize = 16.sp)
),
isDark = true
)
}
// Step 4: Provide theme at app level
@Composable
fun MyApp() {
var isDarkMode by remember { mutableStateOf(false) }
val currentTheme = if (isDarkMode) AppThemes.Dark else AppThemes.Light
CompositionLocalProvider(LocalAppTheme provides currentTheme) {
Scaffold(
topBar = {
TopAppBar(
title = { Text("My App") },
actions = {
IconButton(onClick = { isDarkMode = !isDarkMode }) {
Icon(
imageVector = if (isDarkMode)
Icons.Default.LightMode
else
Icons.Default.DarkMode,
contentDescription = "Toggle theme"
)
}
}
)
}
) { padding ->
MainContent(modifier = Modifier.padding(padding))
}
}
}Consume it anywhere
// Step 5: Use theme throughout the app
@Composable
fun MainContent(modifier: Modifier = Modifier) {
val theme = LocalAppTheme.current
Column(
modifier = modifier
.fillMaxSize()
.background(theme.colors.background)
.padding(16.dp)
) {
Text(
text = "Welcome!",
style = theme.typography.heading,
color = theme.colors.text
)
Spacer(modifier = Modifier.height(16.dp))
// This composable also has access to the theme
ProfileCard()
}
}
@Composable
fun ProfileCard() {
val theme = LocalAppTheme.current
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = theme.colors.surface
)
) {
Column(modifier = Modifier.padding(16.dp)) {
Text(
text = "User Profile",
style = theme.typography.heading,
color = theme.colors.primary
)
Text(
text = "This card automatically updates with the theme!",
style = theme.typography.body,
color = theme.colors.text
)
}
}
}Notice how ProfileCard doesn’t need to receive the theme as a parameter. It simply accesses LocalAppTheme.current and gets the value. When you toggle between light and dark mode, all composables that read from LocalAppTheme automatically recompose with the new values.
That’s the power of CompositionLocal in Jetpack Compose.
Best Practices for Using CompositionLocal
1. Use It Sparingly
CompositionLocal is powerful, but don’t overuse it. It’s perfect for:
- Application-wide themes
- User authentication state
- Locale/language settings
- Navigation controllers
- Dependency injection
It’s NOT ideal for:
- Component-specific state
- Data that changes frequently at the component level
- Communication between sibling composables
2. Always Provide Default Values
Always include a sensible default in your compositionLocalOf lambda:
val LocalUser = compositionLocalOf {
User(id = "123", name = "Anaya", isAuthenticated = false)
}This prevents crashes if someone forgets to provide a value and makes your code more robust.
3. Make CompositionLocals Top-Level Properties
Define them at the file level, not inside composables:
// Good - Top level
val LocalAnalytics = compositionLocalOf { AnalyticsTracker() }
@Composable
fun MyScreen() {
// Bad - Inside composable
val LocalSomething = compositionLocalOf { /* ... */ }
}4. Use Descriptive Names with “Local” Prefix
This convention makes it immediately clear that you’re dealing with a CompositionLocal:
val LocalAppTheme = compositionLocalOf { /* ... */ } // Clear
val theme = compositionLocalOf { /* ... */ } // Confusing5. Document Your CompositionLocals
Add KDoc comments to explain what the CompositionLocal provides and when to use it:
/**
* Provides the current app theme (colors, typography, spacing).
* This value updates when the user switches between light and dark mode.
*/
val LocalAppTheme = compositionLocalOf {
AppTheme(/* ... */)
}Common Pitfalls and How to Avoid Them
Even sometimes experienced developers make mistakes with CompositionLocal in Jetpack Compose. Here are the most common issues:
Pitfall 1: Reading CompositionLocal in Non-Composable Context
// Wrong - Can't use .current outside a composable
class MyViewModel {
val theme = LocalAppTheme.current // Compilation error!
}
// Correct - Pass it as a parameter if needed
@Composable
fun MyScreen(viewModel: MyViewModel) {
val theme = LocalAppTheme.current
viewModel.updateTheme(theme)
}Pitfall 2: Creating New Instances on Every Recomposition
@Composable
fun MyApp() {
// Bad - Creates new theme on every recomposition
CompositionLocalProvider(
LocalAppTheme provides AppTheme(/* ... */)
) {
Content()
}
}
@Composable
fun MyApp() {
// Good - Remember the theme
val theme = remember {
AppTheme(/* ... */)
}
CompositionLocalProvider(LocalAppTheme provides theme) {
Content()
}
}Pitfall 3: Using CompositionLocal for Frequent Updates
// Not ideal - Mouse position changes too frequently
val LocalMousePosition = compositionLocalOf { Offset.Zero }
// Better - Use State or pass as parameter
@Composable
fun TrackingCanvas() {
var mousePosition by remember { mutableStateOf(Offset.Zero) }
// Use mousePosition directly
}Pitfall 4: Forgetting to Provide a Value
If you forget to provide a value, you’ll get the default. This might be okay, or it might be a bug:
val LocalUser = compositionLocalOf<User?> { null }
@Composable
fun MyApp() {
// Forgot to provide a user!
MainScreen()
}
@Composable
fun MainScreen() {
val user = LocalUser.current // Will be null
Text("Hello, ${user?.name}") // Displays "Hello, null"
}Testing Composables with CompositionLocal
When testing composables that rely on a CompositionLocal, you should provide a value using CompositionLocalProvider if the composable depends on that value and no suitable default exists. This allows you to override environment values and test different scenarios.
@Test
fun testThemedButtonUsesCorrectColor() {
composeTestRule.setContent {
// Provide a test theme
val testTheme = AppTheme(
colors = AppColors(
primary = Color.Red,
background = Color.White,
surface = Color.Gray,
text = Color.Black
),
typography = AppTypography(/* ... */),
isDark = false
)
CompositionLocalProvider(LocalAppTheme provides testTheme) {
ThemedButton(text = "Click me", onClick = {})
}
}
composeTestRule.onNodeWithText("Click me")
.assertExists()
.assertHasColor(Color.Red) // Verify theme is actually applied
}This approach lets you test your composables with different CompositionLocal values, ensuring they work correctly in all scenarios.
CompositionLocal vs. Other State Management Solutions
You might wonder when to use CompositionLocal in Jetpack Compose versus other state management approaches. Here’s a quick guide:
Use CompositionLocal when:
- Data is needed by many composables across the tree
- The data represents ambient context (theme, locale, user)
- You want to avoid prop drilling
- The data changes infrequently
Use State/ViewModel when:
- Data is specific to a screen or feature
- You need business logic tied to the data
- The data changes frequently
- You need to survive configuration changes
Use Passed Parameters when:
- Only a few composables need the data
- The relationship is direct parent-child
- You want explicit data flow
Often, the best solution combines these approaches. For example, you might use CompositionLocal for the theme, ViewModels for business logic, and parameters for component-specific props.
FAQ’s
What is CompositionLocal in Jetpack Compose?
CompositionLocal in Jetpack Compose is a mechanism to implicitly pass data down the composable tree without manually passing parameters through every function.
How does CompositionLocal avoid prop drilling?
It provides scoped values that child composables can access directly, eliminating the need to pass the same parameter through multiple intermediate composables.
When should you use CompositionLocal?
Use it for shared, cross-cutting concerns such as themes, configuration, context, or localization. Avoid using it for regular screen state.
Conclusion
Prop drilling isn’t always wrong. But when your composable tree gets deep, it becomes frustrating.
CompositionLocal in Jetpack Compose gives you a clean, structured way to share data across your UI without cluttering every function signature.
Use it thoughtfully.
Keep your dependencies clear.
And treat it as a tool for environmental data, not a replacement for proper state management.
When applied correctly, it makes your Compose code cleaner, more scalable, and easier to reason about.
If you’re building modern Android apps with Kotlin and Jetpack Compose, mastering CompositionLocal is not optional. It’s part of writing professional-level Compose code.
