
How Jetpack Compose passes data through walls and why you’ve been using it all along without knowing
Imagine you’re staring at some Compose code, sipping your third coffee of the day, and then you see it:
CompositionLocalProvider(LocalContentColor provides Color.Red) {
// Some UI stuff
}
And you think: “What sorcery is this?”
Don’t worry. By the end of this article, you won’t just understand CompositionLocalProvider and you’ll know it so well that you could explain it to your grandma. (Okay, maybe a tech-savvy grandma.)
Let’s go for a walk.
The Problem: Threading Data Through a Maze
Imagine you’re building a house. A big one. Like, a 50-room mansion.
Now imagine you need to run electrical wiring to every single room. The traditional way? You’d have to thread that wire through every single wall, room by room, floor by floor.
In Compose terms, that looks like this nightmare:
@Composable
fun App(themeColor: Color) {
Screen(themeColor = themeColor)
}
@Composable
fun Screen(themeColor: Color) {
Content(themeColor = themeColor)
}
@Composable
fun Content(themeColor: Color) {
Card(themeColor = themeColor)
}
@Composable
fun Card(themeColor: Color) {
Title(themeColor = themeColor)
}
@Composable
fun Title(themeColor: Color) {
Text("Hello!", color = themeColor) // FINALLY using it!
}
See the problem?
That themeColor had to travel through four layers of functions that didn’t even care about it. They just had to pass it along like some kind of data hot potato.
This is called prop drilling, and it’s the bane of every UI developer’s existence.
Enter CompositionLocal: The Secret Tunnel System
What if I told you there’s a secret tunnel system built into your mansion?
Instead of threading wires through every wall, you could just… broadcast the electricity. Any room that needs power just taps into the broadcast. No wiring required.
That’s exactly what CompositionLocal does.
It lets you provide data at one point in your UI tree, and any composable anywhere below it can access that data directly — without passing it through every intermediate function.
// Define your "broadcast channel"
val LocalThemeColor = compositionLocalOf { Color.Black }
@Composable
fun App() {
// "Broadcast" the value
CompositionLocalProvider(LocalThemeColor provides Color.Red) {
Screen() // No parameter passing needed!
}
}
@Composable
fun Screen() {
Content() // Still no parameters!
}
@Composable
fun Content() {
Card() // Still nothing!
}
@Composable
fun Card() {
Title() // Keep going!
}
@Composable
fun Title() {
// Just "tune in" to the broadcast
val themeColor = LocalThemeColor.current
Text("Hello!", color = themeColor)
}
Magic? No. Just really clever engineering.
The Three Musketeers: Understanding the Cast
Let’s meet the main characters:
1. CompositionLocal: The Broadcast Channel
Think of this as a named radio frequency. It doesn’t hold any data itself — it’s just an identifier, a key, a “channel number.”
val LocalThemeColor = compositionLocalOf { Color.Black }
// ^^^^^^^^^^^
// Default value (fallback)
2. CompositionLocalProvider: The Radio Tower
This is what actually broadcasts the value. It says: “Hey, for everyone below me in the tree, when you tune into LocalThemeColor, you’ll get Color.Red.”
CompositionLocalProvider(LocalThemeColor provides Color.Red) {
// Everything in here receives Color.Red
}
3. .current: The Radio Receiver
This is how any composable “tunes in” to receive the broadcasted value.
val color = LocalThemeColor.current // "What's playing on this channel?"
Plot Twist: You’ve Been Using This All Along
Remember when you wrote this?
Text(
text = "Hello World",
color = MaterialTheme.colorScheme.primary
)
Guess what MaterialTheme.colorScheme actually is?
object MaterialTheme {
val colorScheme: ColorScheme
@Composable
@ReadOnlyComposable
get() = LocalColorScheme.current // 👀 Look familiar?
}
Yep. Material Theme is just a fancy wrapper around CompositionLocals.
When you wrap your app with MaterialTheme { … }, it’s secretly doing this:
CompositionLocalProvider(
LocalColorScheme provides colorScheme,
LocalTypography provides typography,
LocalShapes provides shapes,
) {
content()
}
You’ve been using the secret tunnel system this whole time. You just didn’t know it.
Under the Hood: How Does This Actually Work?
Okay, time to pop the hood and look at the engine.
The Composition Tree
When Compose runs your code, it doesn’t just execute functions. It builds a tree, a lightweight map of your entire UI structure in memory. This tree records:
- What composables exist
- Where they are in the hierarchy
- What state they hold
Think of it as a family tree for your UI components.
The Slot Table
At the heart of Compose is something called the Slot Table a flat, contiguous memory structure that stores all composition data efficiently. It’s like a super-optimized spreadsheet that tracks everything about your UI.
How CompositionLocal Fits In
When you call CompositionLocalProvider:
- Compose records the binding “At this point in the tree, LocalThemeColor = Color.Red”
- Children inherit by default. Every composable below this point can see this binding
- Lookup is hierarchical. When a child calls .current, Compose walks UP the tree until it finds a provider (or uses the default)
It’s like a family inheritance system. Children inherit from parents unless someone explicitly changes the inheritance.
The Two Flavors: Choose Your Fighter
Here’s where it gets interesting. There are two ways to create a CompositionLocal:
Option 1: compositionLocalOf
val LocalThemeColor = compositionLocalOf { Color.Black }
Characteristics:
- Tracks readers: Compose knows exactly which composables are “tuned in”
- Smart recomposition: When the value changes, ONLY the readers recompose
- Read cost: Slightly expensive (tracking overhead)
- Write cost: Cheap (targeted invalidation)
Use when: The value might change during the app’s lifetime (themes, user preferences, dynamic configs)
Option 2: staticCompositionLocalOf
val LocalContext = staticCompositionLocalOf<Context> {
error("No Context provided")
}
Characteristics:
- No tracking: Compose doesn’t track who’s reading
- Nuclear recomposition: When the value changes, THE ENTIRE SUBTREE recomposes
- Read cost: Very cheap (no overhead)
- Write cost: EXPENSIVE (invalidates everything below)
Use when: The value will NEVER change (Android Context, font loaders, static configuration)
The Mental Model
Think of it like a notification system:
- compositionLocalOf = Text messages. Compose has everyone’s phone number. When something changes, it texts only the people who need to know.
- staticCompositionLocalOf = Air raid siren. When something changes, EVERYONE in the city hears it and reacts, whether they needed to know or not.
Real-World Use Cases (That Will Actually Come Up in Your Career)
1. Theming (The Classic)
val LocalAppColors = compositionLocalOf { lightColors() }
@Composable
fun AppTheme(
darkTheme: Boolean = isSystemInDarkTheme(),
content: @Composable () -> Unit
) {
val colors = if (darkTheme) darkColors() else lightColors()
CompositionLocalProvider(LocalAppColors provides colors) {
content()
}
}
// Anywhere in your app:
val colors = LocalAppColors.current
2. Providing Android Context
val LocalAppContext = staticCompositionLocalOf<Context> {
error("No Context provided")
}
// At the root:
CompositionLocalProvider(LocalAppContext provides applicationContext) {
App()
}
// Anywhere you need context:
val context = LocalAppContext.current
Toast.makeText(context, "Hello!", Toast.LENGTH_SHORT).show()
3. Navigation Controller
val LocalNavController = staticCompositionLocalOf<NavController> {
error("No NavController provided")
}
// Use it anywhere:
val navController = LocalNavController.current
Button(onClick = { navController.navigate("settings") }) {
Text("Go to Settings")
}
4. Feature Flags
val LocalFeatureFlags = compositionLocalOf { FeatureFlags() }
// Conditionally show features:
val features = LocalFeatureFlags.current
if (features.newCheckoutEnabled) {
NewCheckoutButton()
} else {
OldCheckoutButton()
}
5. Dependency Injection Light
val LocalAnalytics = staticCompositionLocalOf<AnalyticsService> {
error("No AnalyticsService provided")
}
// Log events from anywhere:
val analytics = LocalAnalytics.current
analytics.logEvent("button_clicked")
The Dark Side: Anti-Patterns to Avoid
With great power comes great responsibility. Here’s how NOT to use CompositionLocal:
❌ Anti-Pattern 1: Using It for Convenience (Lazy Prop Passing)
// DON'T DO THIS
val LocalUserName = compositionLocalOf { "" }
@Composable
fun UserProfile() {
val userName = LocalUserName.current // Hidden dependency!
Text(userName)
}
Why it’s bad: If UserProfile ALWAYS needs a user name, make it explicit:
// DO THIS INSTEAD
@Composable
fun UserProfile(userName: String) { // Clear dependency!
Text(userName)
}
❌ Anti-Pattern 2: Business Logic in CompositionLocals
// DON'T DO THIS
val LocalUserRepository = compositionLocalOf<UserRepository> { ... }
@Composable
fun SomeScreen() {
val repo = LocalUserRepository.current
val user = repo.fetchUser() // Fetching data in composition!
}
Why it’s bad: CompositionLocals are for UI concerns (themes, navigation, context), not business logic. Use proper architecture (ViewModel, UseCase) for data.
❌ Anti-Pattern 3: Using staticCompositionLocalOf for Changing Values
// DON'T DO THIS
val LocalThemeColor = staticCompositionLocalOf { Color.Black }
// Later, when you change it:
CompositionLocalProvider(LocalThemeColor provides newColor) {
// ENTIRE subtree recomposes! Performance disaster!
}
Why it’s bad: If the value changes, you nuke your performance. Use compositionLocalOf instead.
❌ Anti-Pattern 4: Over-relying on Defaults
// RISKY
val LocalUserSession = compositionLocalOf<UserSession?> { null }
@Composable
fun ProfileScreen() {
val session = LocalUserSession.current
// What if it's null? Silent bug!
Text(session!!.userName) // 💥 Crash waiting to happen
}
Better approach:
val LocalUserSession = compositionLocalOf<UserSession> {
error("UserSession not provided! Wrap with SessionProvider.")
}
Now if you forget to provide it, you get a clear error instead of a mystery crash.
The Decision Framework: When to Use CompositionLocal
Ask yourself these questions:
✅ USE CompositionLocal When:
- Many composables need the value: Theme colors used across 50+ components
- The value is truly ambient: It’s environmental context, not business data
- Threading parameters would be impractical: 10+ layers of prop drilling
- The value is related to UI behavior: Colors, typography, spacing, navigation
❌ DON’T Use CompositionLocal When:
- Only a few composables need it: Just pass the parameter
- It’s business/domain data: Use ViewModel/State management
- You want to skip a few levels of parameters — That’s lazy, not smart
- The dependency should be explicit: If a component REQUIRES something, make it a parameter
Pro Tips
Tip 1: Name Your Locals with the Local Prefix
val LocalAppTheme = compositionLocalOf { ... } // ✅ Good
val AppTheme = compositionLocalOf { ... } // ❌ Confusing
This is the convention. Follow it. Your teammates will thank you.
Tip 2: Group Related Locals Together
object AppLocals {
val Theme = compositionLocalOf { AppTheme() }
val Analytics = staticCompositionLocalOf<Analytics> { ... }
val Navigation = staticCompositionLocalOf<NavController> { ... }
}
Tip 3: Create Helper Extensions
val LocalAppTheme = compositionLocalOf { AppTheme() }
// Instead of LocalAppTheme.current everywhere:
val appTheme: AppTheme
@Composable
@ReadOnlyComposable
get() = LocalAppTheme.current
// Now you can just write:
Text(color = appTheme.primaryColor)
Tip 4: Provide Multiple Values at Once
CompositionLocalProvider(
LocalTheme provides darkTheme,
LocalAnalytics provides analytics,
LocalNavigation provides navController,
LocalUserSession provides session,
) {
App()
}
Clean and organized.
Tip 5: Test with Custom Providers
@Test
fun testUserProfile() {
composeTestRule.setContent {
CompositionLocalProvider(
LocalUserSession provides mockUserSession
) {
UserProfile()
}
}
// Assertions...
}
CompositionLocals make testing easier because you can swap out dependencies.
The Mental Model That Sticks
Here’s how I want you to remember CompositionLocal forever:
Imagine your Compose tree is a building.
- Regular parameters = Hand-delivering a package to each floor, room by room
- CompositionLocal = A pneumatic tube system. Drop something in at floor 1, and any room can grab it
- compositionLocalOf = Smart pneumatic tubes that notify only the rooms waiting for packages
- staticCompositionLocalOf = Dumb pneumatic tubes that announce to the ENTIRE building when something arrives
Quick Reference Cheat Sheet

If this article helped you understand CompositionLocal, give it a clap (or 50). And if you’re still confused about anything, drop a comment, I read them all.
Follow for more deep dives into Android internals.
CompositionLocalProvider: The Secret Tunnel System Your Compose UI Didn’t Tell You About was originally published in ProAndroidDev on Medium, where people are continuing the conversation by highlighting and responding to this story.




This Post Has 0 Comments