When to Use @Immutable and @ReadOnlyComposable in Jetpack Compose?
I was implementing design tokens from Figma into Jetpack Compose. I created some data classes for colors and typography, then noticed my recomposition metrics were worse than expected. Changing one color was triggering recomposition in unrelated components. Something was wrong.
I tried looking at the Compose Compiler Report. My composables weren’t marked as “skippable.” They were just “restartable.” That meant Compose was recomposing them even when nothing changed.
ThemedCard: restartableI needed to understand why. Compose needs to know if a type is stable to skip recomposition. If a type’s properties might change, Compose has to recompose to be safe.
Type is stable? | |-- YES --> Can skip recomposition if unchanged | |-- NO --> Must always recomposeCompose automatically knows some types are stable: primitives like Int, String, Float, and function types. But for my custom data classes, Compose wasn’t sure.
data class BgTokens( val primary: Color, val secondary: Color, val surface: Color, val onSurface: Color,)
data class TextTokens( val heading: Color, val body: Color, val caption: Color,)
data class AppColorTokens( val bg: BgTokens, val text: TextTokens,)The problem? Compose couldn’t verify that the nested Color objects wouldn’t mutate. Even though I used val, Compose doesn’t trust third-party types by default.
I tried adding @Immutable annotations:
@Immutabledata class BgTokens( val primary: Color, val secondary: Color, val surface: Color, val onSurface: Color,)
@Immutabledata class TextTokens( val heading: Color, val body: Color, val caption: Color,)
@Immutabledata class AppColorTokens( val bg: BgTokens, val text: TextTokens,)@Immutable tells Compose: this object will never change after creation. All val properties, no mutation. Safe to skip recomposition.
ThemedCard: restartable skippableBut then I ran into another issue. I created a composable to access my tokens:
@Composablefun AppColorTokens(): AppColorTokens = LocalAppTokens.currentThis worked, but I was still seeing overhead in my composition traces. A commenter on Reddit suggested using @ReadOnlyComposable instead.
@Composable@ReadOnlyComposableval colors: AppColorTokens get() = LocalAppTokens.current@ReadOnlyComposable marks a function as read-only. It doesn’t modify state. This gives Compose two benefits:
1. Thread Safety - Can be called from any thread2. No State Tracking - Skip recomposition tracking overhead3. Aggressive Caching - Results can be cached more freelyI learned when to use each annotation. @Immutable is for data classes with only val properties. Types that will never change.
@Immutabledata class ThemeColors( val primary: Color, val secondary: Color, val surface: Color,)
@Immutabledata class TypographyTokens( val heading: TextStyle, val body: TextStyle, val caption: TextStyle,)Don’t use @Immutable for types with mutable state. That’s wrong and dangerous.
@Immutable // WRONG!class CounterState { var count: Int = 0 // This changes!}Use @Stable for mutable but observable state instead:
@Stableclass CounterState { var count: Int by mutableStateOf(0)}@ReadOnlyComposable is for accessor functions. Functions that only read and return values.
@Composable@ReadOnlyComposableval themeColors: ColorScheme get() = MaterialTheme.colorScheme
@Composable@ReadOnlyComposableval typography: Typography get() = MaterialTheme.typography
@Composable@ReadOnlyComposableval colors: AppColorTokens get() = LocalAppTokens.currentHere’s the complete pattern I ended up with for design tokens:
// 1. Immutable data classes@Immutabledata class BgTokens( val primary: Color, val secondary: Color, val surface: Color, val onSurface: Color,)
@Immutabledata class TextTokens( val heading: Color, val body: Color, val caption: Color,)
@Immutabledata class AppColorTokens( val bg: BgTokens, val text: TextTokens,)
// 2. Composition local providerval LocalAppTokens = staticCompositionLocalOf<AppColorTokens> { error("AppTokens not provided")}
// 3. Read-only accessor@Composable@ReadOnlyComposableval colors: AppColorTokens get() = LocalAppTokens.current
// 4. Usage in composables@Composablefun ThemedCard() { val colors = colors
Card( colors = CardDefaults.cardColors( containerColor = colors.bg.surface, contentColor = colors.text.body, ) ) { Text( text = "Hello", color = colors.text.heading, ) }}I verified the impact with the Compose Compiler Report. Before annotations, most composables were just restartable. After, they were restartable skippable.
restartable skippable -> Ideal (can skip recomposition)restartable -> Needs optimization(none) -> Not restartable or skippableI also learned about collections. Compose treats List, Map, Set as unstable by default.
@Immutabledata class UserList( val users: List<User> // Unstable!)I have two options: use kotlinx immutable collections, or wrap in a stable type.
@Immutabledata class UserList( val users: ImmutableList<User> // from kotlinx.collections.immutable)@Immutabledata class UserList( private val _users: List<User>) { val users: List<User> get() = _users.toList()}Here’s a quick reference:
| Annotation | When to Use | Effect |
|---|---|---|
@Immutable | Data classes with only val | Never changes |
@Stable | Classes with mutable observable state | Safely observable |
@ReadOnlyComposable | Functions that only read state | Optimized read access |
The key insight: Compose needs help understanding your types. These annotations tell Compose how your code behaves. Use them correctly, and Compose can skip unnecessary recomposition. Use them wrong, and you might get bugs or weird behavior.
I tested my annotations to make sure everything worked:
@Testfun `immutable token classes are stable`() { val bgTokens = BgTokens( primary = Color.Red, secondary = Color.Blue, surface = Color.White, onSurface = Color.Black, )
val bgTokens2 = BgTokens( primary = Color.Red, secondary = Color.Blue, surface = Color.White, onSurface = Color.Black, )
assertEquals(bgTokens, bgTokens2) assertEquals(bgTokens.hashCode(), bgTokens2.hashCode())}My recomposition metrics improved after these changes. Compose was skipping more recompositions, and my app ran smoother. The annotations are small additions, but they make a big difference in how Compose optimizes your code.
Final Words + More Resources
My intention with this article was to help others share my knowledge and experience. If you want to contact me, you can contact by email: Email me
Here are also the most important links from this article along with some further resources that will help you in this scope:
- 👨💻 Compose Performance Stability
- 👨💻 Immutable and Stable Types
- 👨💻 Jetpack Compose Performance Guide
- 👨💻 Reddit Discussion Thread
Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!
Comments