CompositionLocal in Jetpack Compose
I’ve been using Jetpack Compose for quite some time already. Although I occasionally use LocalContext or related APIs, such as LocalResources, and have read blogs about CompositionLocal, I never fully understood how it works.
Recently, I took some time to experiment and finally got my head around it.
In this blog, I will show you some examples from my playground that helped me understand how it works.
Like me, you’re probably using LocalContext quite often. Even though it’s not recommended to get strings from it and you should use LocalResources instead, there are still plenty of cases in your code where you need our beloved God object Context.
But how are Context or Resources actually being provided?
To answer this question, let’s quickly recap how Compose works with a simple example:
@Composable
fun Greeting(
analytics: Analytics,
modifier: Modifier = Modifier
) {
analytics.trackEvent("Greeting (Re)composed")
Text(
modifier = modifier,
text = "Hello World!"
)
}Basically, you have to pass properties — such as the Analytics object in this case — to a composable function so they can be used. Additionally, the Compose runtime can detect when a property changes and, if so, recompose the function to make use of the updated value.
I won’t go into more detail here; I’ll just assume you’re familiar with how this works.
With LocalContext, this is different, right? Somehow, you don’t have to pass the Context to the composable function, you can just get it from… “somewhere”?!
This is exactly what CompositionLocal is designed for. You can access “anything” in a composable function without providing it as a parameter. All the other benefits of the Composable runtime remain intact. So if the Context changes for whatever reason, the composable function will also be recomposed. Just as if it had been provided as a parameter.
Let me demonstrate how it works with an example. We’ll create our own LocalAnalytics, so we no longer have to pass it as a parameter to the function.
Let’s start with the updated Greeting composable, which no longer requires Analytics to be passed as a parameter:
@Composable
fun Greeting(modifier: Modifier = Modifier) {
val analytics = LocalAnalytics.current
analytics.trackEvent("Greeting (Re)composed")
Text(
modifier = modifier,
text = "Hello World!"
)
}In this composable, we use LocalAnalytics to track events whenever the composable is composed or recomposed.
Analytics itself is just a simple data class that takes an Analytics.Provider and has a trackEvent function to send the given eventName to the respective Provider:
data class Analytics(private val provider: Provider) {
sealed interface Provider {
object LogCat : Provider
object Println : Provider
object Firebase : Provider
}
fun trackEvent(eventName: String) {
when (provider) {
Provider.LogCat -> println("Debug: LogCat - Tracking event: $eventName")
Provider.Println -> println("Debug: Println - Tracking event: $eventName")
Provider.Firebase -> println("Debug: Firebase - Tracking event: $eventName")
}
}
}To create LocalAnalytics, we can use of the compositionLocalOf function, which takes a default value:
val LocalAnalytics = compositionLocalOf { Analytics(Analytics.Provider.Println) }At this point, we could already use the LocalAnalytics inside our Greeting composeable, but it would always give us the default Analytics.Provider.Println. Most likely, we want to use the current provided tracking provider, which might differ from the default.
To correctly provide an Analytics instance with LocalAnalytics, we need to wrap the Greeting composable from the caller composable into a CompositionLocalProvider:
var analyticsProvider by remember { mutableStateOf<Analytics.Provider>(Analytics.Provider.Firebase) }
CompositionLocalProvider(LocalAnalytics provides Analytics(analyticsProvider)) {
Greeting()
}At this point, we provide an Analytics object other than the default, using Analytics.Provider.Firebase instead of Analytics.Provider.Println.
The full source code can also be found in this GitHub Gist.
“But how is this different from a singleton?” I asked myself after writing this. Making Analytics a singleton, as the following code demonstrates, would solve the same problem, right?
object Analytics {
lateinit var provider: Provider
/** The rest of the original code */
}Instead of providing the Analytics implementation through a CompositionLocalProvider, I can just assign a value to the provider property and use it in the Greeting composable as before.
It’s exactly the same thing, but without Compose involved.
So, why not go this way?
Ignoring the fact that singletons are also sometimes considered an anti-pattern.
After a bit of research, I figured out two main (and important) reasons:
- As I mentioned earlier,
Local*properties are tracked by the Compose runtime in the same way as parameters in a composable function. So if a value changes and is tracked as a composableStateobject, all readers of it will automatically be recomposed. - This is probably the main argument. You can “override” the provided value (the
Local*properties) at any time in your view hierarchy. This is likely why the naming convention Local was chosen. You can nest as many asCompositionLocalProvideras you want, providing differentLocal*.currentvalues to their consumers.
To demonstrate the first case, let’s introduce radio buttons that allow changing the Analytics.Provider at runtime in another composable than Greeting:
var analyticsProvider by remember { mutableStateOf<Analytics.Provider>(Analytics.Provider.Println) }
CompositionLocalProvider(LocalAnalytics provides AnalyticsTracking(analyticsProvider)) {
Column {
RadioButton(
text = "Println",
selected = analyticsProvider is Analytics.Provider.Println,
onClick = { analyticsProvider = Analytics.Provider.Println }
)
RadioButton(
text = "LogCat",
selected = analyticsProvider is Analytics.Provider.LogCat,
onClick = { analyticsProvider = Analytics.Provider.LogCat }
)
RadioButton(
text = "Firebase",
selected = analyticsProvider is Analytics.Provider.Firebase,
onClick = { analyticsProvider = Analytics.Provider.Firebase }
)
Greeting()
}
}Don’t get confused by RadioButton. It’s nothing fancy. Just a simple wrapper around the material3.RadioButton:
@Composable
private fun RadioButton(
text: String,
selected: Boolean,
onClick: () -> Unit,
) {
Row {
androidx.compose.material3.RadioButton(
selected = selected,
onClick = onClick
)
Text(
text = text,
modifier = Modifier
.padding(start = 8.dp)
.align(Alignment.CenterVertically)
)
}
}So, what is the code doing? Each time a RadioButton is selected, the analyticsProvider changes, and so does the Analytics instance. Since this is tracked via the CompositionLocalProvider, any reader of LocalAnalytics.current will be notified of the change, and our Greeting function will recompose.
To explain the second case, imagine you want to track a button click. The click event should be named based on the location in the view hierarchy, so you know exactly where a button was clicked. The tracking event name would then be something like view1_view2_viewN_buttonClick.
The simple approach is to figure out where the button is located and name the event accordingly. But this is fragile. What happens if you move composables around or remove an intermediate view? Will you remember updating the tracking name? Or what if the button is a reusable component, hidden somewhere in a composable library in your Gradle build? Do you really want to pass the event name as a parameter? No, you don’t 😉
The solution with CompositionLocal is to “stack” them. You can simply create another Analytics instance, provide it, and wrap your button in it.
With this approach, your button, whether it’s in your current view or comes from a common composable library, doesn’t need the Analytics instance or the event name passed to it. The button itself doesn’t need to be changed. Ever. It simply takes the last provided Analytics instance via LocalAnalytics.current and tracks the event with the generic event name buttonClick.
Generally speaking, the Local*.current property will always return the “last provided” (some may also call it “nearest provided”) object in the view hierarchy.
The following example shows you how this could be implemented:
Both cases I described can’t be done easily with a singleton, not to mention other problems a singleton may introduce.
That’s it! I hope this blog helped you to understand LocalComposition a bit better. The examples shown here, as well as writing this blog, helped me finally understand it. If you’re still struggling, don’t be discouraged. Just start playing around with it. Create your own samples, and at some point, it will click, just like it did for me. 😉
Happy coding!
