Did you ever need to pass irrelevant parameters through multiple composable functions just to forward a parameter down the composable tree somewhere?
That’s exactly the problem Composition Locals solve! In this article, we’ll discover what Composition Locals are, why they’re so useful, and how to create your own.
What Are Composition Locals?
Composition Locals are like invisible messengers in Jetpack Compose. They allow any child composable — no matter how deeply nested — to read values provided higher up the composition tree. If you’ve used React Native, they’re similar to the Context API, which solves the “prop drilling” problem (passing props through components that don’t directly need them).
Example: Instead of passing a theme
object to every composable, you can provide it once and have child composables fetch it as needed.
The Problem They Solve
Here’s a typical example without Composition Locals:
@Composable fun MyApp(theme: Theme) { MainScreen(theme = theme) } @Composable fun MainScreen(theme: Theme) { Column { Header(theme = theme) Content(theme = theme) } } @Composable fun Header(theme: Theme) { // Use theme } @Composable fun Content(theme: Theme) { // Use theme }
Every composable has a theme
parameter, even if it doesn’t use it. This leads to “parameter pollution” and makes the code harder to maintain.
Why Are Composition Locals Important?
- Reduced Boilerplate
Provide data once at a higher level, consume it wherever necessary. - Better Encapsulation
Intermediate composables don’t need to manage data they don’t directly use. - Easier Testing
You can override Composition Locals in tests without changing public APIs. - Scoped Values
Data is only available to composables within the specific composition scope.
What Composition Locals Are (and Aren’t)
Due to a comment in LinkedIn about this post, let me make something clear…
CompositionLocals shine when you’re dealing with cross-cutting UI stuff like theming or spacing. That’s where they save you from passing the same parameters through every composable.
But for actual app logic its better to avoid using them.
Are For:
- Design system tokens (themes, spacing, typography)
- UI infrastructure (keyboard controller, focus management)
- Cross-cutting UI concerns that follow the visual hierarchy
- Values that need different overrides at different levels of your UI tree
Aren’t For:
- Business logic or application state
- Data that should be managed by state management solutions
- Values that affect application behavior — Database connections, repositories, or other non-UI dependencies
- Avoiding proper dependency injection
Think of Composition Locals as part of your UI toolkit, not as a general dependency injection mechanism. They shine when dealing with UI-related values that naturally follow your component hierarchy, but shouldn’t be used to bypass proper architecture patterns or hide important dependencies.
A good rule of thumb:
– if it’s about how things look or interact at the UI level, a CompositionLocal might be appropriate.
– If it’s about what your app does, use proper state management or dependency injection instead.
Creating and Using Composition Locals
1. Declare a Composition Local
// For dynamic values that change frequently val LocalTheme = compositionLocalOf<Theme> { error("No theme provided!") } // For values that rarely or never change at runtime val LocalTheme = staticCompositionLocalOf { Theme.Light }
- staticCompositionLocalOf: For values that never or rarely change during runtime (like design tokens—typography, spacing, etc.). This doesn’t automatically trigger recompositions if the value changes, making it more efficient.
- compositionLocalOf: For dynamic values that change frequently (like a theme toggled by the user). When the provided value updates, any composable consuming it will automatically recompose.
2. Provide a Value
@Composable fun MyApp() { val theme = Theme.Dark CompositionLocalProvider(LocalTheme provides theme) { MainScreen() } }
3. Consume the Value
@Composable fun MyComponent() { val theme = LocalTheme.current // Use theme }
. . .
Comparing to React Native’s Context API
If you’re familiar with React Native’s Context API, Composition Locals feel very similar:
Jetpack Compose Example:
val LocalTheme = compositionLocalOf { Theme.Light } @Composable fun App() { CompositionLocalProvider(LocalTheme provides Theme.Dark) { ScreenA() } } @Composable fun Header() { val theme = LocalTheme.current Text( text = "Title", color = if (theme == Theme.Dark) Color.White else Color.Black ) }
React Native Example:
// Create context const ThemeContext = React.createContext(Theme.Light); function App() { return ( <ThemeContext.Provider value={Theme.Dark}> <MainScreen /> </ThemeContext.Provider> ); } function Header() { const theme = React.useContext(ThemeContext); return <h1 style={{ color: theme === Theme.Dark ? "#fff" : "#000" }}>Title</h1>; }
Both approaches solve prop drilling, allow subtree overrides, and supply default values — but differ in syntax and recomposition mechanics.
Understanding Scope: Not Global by Default
A crucial point: Composition Locals are not automatically global! They’re scoped to wherever you provide them:
@Composable fun App() { // LocalTheme is Dark only in ScreenA's hierarchy CompositionLocalProvider(LocalTheme provides Theme.Dark) { ScreenA() } // ScreenB uses default or throws an error if no default is set ScreenB() // LocalTheme is Light only in ScreenC's hierarchy CompositionLocalProvider(LocalTheme provides Theme.Light) { ScreenC() } }
You can override values deeper down:
@Composable fun ScreenA() { // Using Dark theme from parent Header() CompositionLocalProvider(LocalTheme provides Theme.Light) { // Overrides Dark theme SpecialSection() } // Back to Dark Footer() }
. . .
Making Them “Global”
If you want values available throughout your entire app, provide them at the top level:
@Composable fun MyApp() { CompositionLocalProvider( LocalTheme provides appTheme, LocalSpacing provides appSpacing ) { AppContent() } }
That said, truly global state (like user profiles, auth tokens, or server data) often belongs in a dedicated state management solution (e.g., ViewModel, Hilt, Redux, etc.).
Real-World Example: Spacing System
Let’s say we want a custom spacing system:
// 1. Define a spacing data class data class Spacing(val small: Dp = 4.dp, val medium: Dp = 8.dp, val large: Dp = 16.dp) // 2. Create a CompositionLocal val LocalSpacing = compositionLocalOf { Spacing() } // 3. Provide custom spacing for a specific screen @Composable fun SpecialScreen() { CompositionLocalProvider(LocalSpacing provides Spacing(8.dp, 16.dp, 24.dp)) { CustomCard("Hello", "This card uses custom spacing") } } // 4. Consume it @Composable fun CustomCard(title: String, content: String) { val spacing = LocalSpacing.current Card(modifier = Modifier.padding(spacing.medium)) { Column(modifier = Modifier.padding(spacing.medium)) { Text(title, modifier = Modifier.padding(bottom = spacing.small)) Text(content) } } }
. . .
@Preview Composables
An important gotcha: @Preview composables are completely isolated from your app’s composition tree. This means they can’t access any CompositionLocals that you’ve provided elsewhere in your app, even at the root level!
// In your app @Composable fun MyApp() { CompositionLocalProvider(LocalTheme provides Theme.Dark) { AppContent() // All children here can access LocalTheme } } // In your preview - THIS WON'T WORK! @Preview @Composable fun SomeComponentPreview() { MyComponent() // CRASH! No access to LocalTheme }
Instead, you need to provide all necessary CompositionLocals again in your preview:
@Preview @Composable fun SomeComponentPreview() { CompositionLocalProvider(LocalTheme provides Theme.Dark) { MyComponent() // Now it works! } }
To avoid repeating this setup in every preview, create a preview wrapper:
@Composable private fun PreviewWrapper( content: @Composable () -> Unit ) { CompositionLocalProvider( LocalTheme provides Theme.Light, LocalSpacing provides Spacing(), LocalContentColor provides Color.Black, // Add other necessary CompositionLocals ) { Surface { // Optional: provide a surface for proper theming content() } } } // Now you can easily create previews @Preview @Composable fun ComponentPreview() { PreviewWrapper { MyComponent() } }
This approach:
- Makes previews more maintainable
- Ensures consistency across previews
- Reduces boilerplate
- Makes it easy to add preview variants
Understanding Thread Usage with CompositionLocals
- The
.current
property is synchronized with Compose’s composition system - You can’t accidentally access CompositionLocals from background threads because they’re only available in @Composable functions
// This is safe - always runs on the main thread during composition @Composable fun SafeComponent() { val theme = LocalTheme.current // Thread-safe access } // This would crash - trying to access outside composition CoroutineScope(Dispatchers.IO).launch { val theme = LocalTheme.current // CRASH: Wrong thread and not in composition }
2. Value Updates
- Changes to CompositionLocal values are always synchronized with recomposition
- Updates are atomic — you’ll never get partial or inconsistent values
- All readers see the same value at the same point in the composition
@Composable fun ThemeProvider(theme: Theme) { // Thread-safe: Updates are synchronized with composition CompositionLocalProvider(LocalTheme provides theme) { Content() } }
3. Background Thread Considerations
If you need to use CompositionLocal values in background work:
@Composable fun BackgroundWorker() { val theme = LocalTheme.current // Safely capture during composition LaunchedEffect(theme) { withContext(Dispatchers.IO) { // Safe: using captured value, not accessing .current processTheme(theme) } } }
Job Offers
Usage Rules
What is .current
?
The .current property is how you actually read a value from a CompositionLocal. Think of it as the “getter” that retrieves the most recently provided value in the composition tree above your composable.
// Defining a CompositionLocal val LocalTheme = compositionLocalOf<Theme> { Theme.Light } @Composable fun MyComponent() { // Using .current to read the value val theme = LocalTheme.current // This gets the closest provided Theme value Text( text = "Hello", color = theme.textColor // Using the retrieved theme ) }
Built-in CompositionLocals
Jetpack Compose already comes with many useful Composition Locals. For instance:
LocalContext: Fetches the Android
Context
inside a composable.LocalSoftwareKeyboardController: Helps you control the software keyboard (show/hide).
LocalFocusManager: Manages focus state across composables.
These are excellent examples of how Composition Locals make it easy to access shared resources without needing to pass them around everywhere.
Let’s see how to use
.current
with some built-in CompositionLocals:
@Composable fun ExampleWithBuiltInLocals() { // Getting the Android Context val context = LocalContext.current // Getting the keyboard controller val keyboardController = LocalSoftwareKeyboardController.current // Getting the focus manager val focusManager = LocalFocusManager.current Button(onClick = { // Using the retrieved values Toast.makeText(context, "Clicked!", Toast.LENGTH_SHORT).show() keyboardController?.hide() // Note: keyboard controller might be null focusManager.clearFocus() }) { Text("Click Me") } }
1. Only Use .current in @Composable
Functions
Composition Locals are tied to Compose’s composition system. The .current
property can only be accessed during composition, which happens within @Composable functions. Trying to access it elsewhere will crash your app because the composition system isn’t active to provide the value.
// WRONG - Will crash! class MyViewModel { private val theme = LocalTheme.current // Crash: Not in a @Composable } // WRONG - Will crash! fun normalFunction() { val theme = LocalTheme.current // Crash: Not in a @Composable } // RIGHT @Composable fun MyComponent() { val theme = LocalTheme.current // OK: Inside a @Composable }
2. Don’t Store .current Values
CompositionLocal values can change during recomposition. If you store a .current
value in a property or long-lived variable, that stored value might become stale when the actual CompositionLocal value changes. Always read the value fresh when you need it.
// WRONG @Composable fun BadExample() { // Don't store in properties or variables that outlive the composition class StateHolder { val theme = LocalTheme.current // BAD: Value might become stale } } // RIGHT @Composable fun GoodExample() { // Read the value each time you need it val theme = LocalTheme.current Button( onClick = { /* ... */ }, colors = ButtonDefaults.buttonColors( backgroundColor = theme.buttonColor // Fresh value each recomposition ) ) { Text("Click Me") } }
3. Passing to Non-Composable Code
Sometimes you need to use CompositionLocal values in regular classes or functions. The safe way to do this is to read the value in your composable and pass it as a parameter. This ensures the non-composable code always has the current value and maintains proper separation of concerns.
class ThemeAwareHelper(private val theme: Theme) { fun getStyledText(text: String): SpannedString { // Use theme safely here } } @Composable fun MyComponent() { val theme = LocalTheme.current val helper = remember(theme) { ThemeAwareHelper(theme) } // Use helper safely }
4. Accessing in Side Effects
When using CompositionLocal values in side effects (like LaunchedEffect
or SideEffect
), capture the value during composition and use it as a parameter to ensure the side effect reruns when the value changes.
@Composable fun MyComponent() { val theme = LocalTheme.current // Side effect will relaunch if theme changes LaunchedEffect(theme) { // Safe to use theme here as it's captured from composition analytics.logThemeUsed(theme.name) } // DisposableEffect example DisposableEffect(theme) { val themeListener = ThemeListener(theme) themeListener.start() onDispose { themeListener.stop() } } }
5. Multiple CompositionLocals
When your composable needs multiple CompositionLocal values, read each one individually at the top level of your composable. This makes it clear what values your composable depends on and ensures you’re always working with fresh values.
@Composable fun MyComponent() { // Read all needed values at the start val theme = LocalTheme.current val spacing = LocalSpacing.current val context = LocalContext.current Column( modifier = Modifier.padding(spacing.medium) ) { Text( text = "Hello", style = theme.typography.body1, color = theme.textColor ) Button(onClick = { // Using context safely Toast.makeText(context, "Clicked!", Toast.LENGTH_SHORT).show() }) { Text("Click Me") } } }
These rules ensure that:
- Your app doesn’t crash from incorrect CompositionLocal access
- Values stay up-to-date when CompositionLocals change
- Side effects and non-composable code work correctly with CompositionLocal values
- Your code remains maintainable and predictable
- Recomposition works properly with your CompositionLocal usage
Practical Examples with MVI/MVVM
Even if you’re using MVI (Model-View-Intent) or MVVM, Composition Locals remain helpful for UI infrastructure or cross-cutting concerns — like theming, feature flags, or navigation — while your state management handles the business logic and data flow. Here are some real-world scenarios:
- Design System
UsestaticCompositionLocalOf
for values such as colors, typography, and spacing. These design tokens rarely change at runtime, so a static local avoids unnecessary recompositions. - Snackbar Manager
Make a sharedSnackbarManager
accessible via a Composition Local. Any screen can trigger a global snackbar message without passing extra parameters or polluting the ViewModel. - Feature Flags / A/B Testing
Store feature flags in a Composition Local so your UI can easily reflect whether a feature is enabled or disabled. This prevents flag data from cluttering your application’s primary state. - Accessibility Settings
Provide user accessibility preferences (font scale, reduce motion, high contrast, etc.) through a Composition Local, allowing any composable to adapt UI based on these settings.
By leveraging Composition Locals for these cross-cutting concerns, your MVI/MVVM architecture remains focused on state and logic, while the UI benefits from easier access to shared resources.
Best Practices
- Choose the Right Type
UsecompositionLocalOf
for values that change andstaticCompositionLocalOf
for values that rarely change. - Provide Default Values
Always give a sensible default or throw an error when creating your CompositionLocal. - Limit Scope
Avoid providing everything globally. Keep values in the smallest scope needed. - Document Usage
Help other developers (and future you!) understand when and how to use your Composition Locals. - Consider Performance
Changes incompositionLocalOf
cause recomposition, whilestaticCompositionLocalOf
does not.
Conclusion
Composition Locals are a powerful way to share data in Jetpack Compose. They shine when you have UI-related values (theme, spacing, navigation, etc.) that need to be accessible throughout your composables — without the hassle of passing parameters everywhere.
However, remember that they’re not a replacement for proper state management. Keep your app-specific or user interaction data in your MVI/MVVM state, and let Composition Locals handle cross-cutting concerns and design tokens.
By using Composition Locals effectively alongside a robust architecture, you’ll write cleaner, more scalable, and easier-to-test Compose code.
This article is previously published on proandroiddev.com.