Skip to content

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.

Compose Compiler Report - Before
ThemedCard: restartable

I 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.

Stability Decision Flow
Type is stable?
|
|-- YES --> Can skip recomposition if unchanged
|
|-- NO --> Must always recompose

Compose automatically knows some types are stable: primitives like Int, String, Float, and function types. But for my custom data classes, Compose wasn’t sure.

DesignTokens.kt - Before
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:

DesignTokens.kt - After
@Immutable
data class BgTokens(
val primary: Color,
val secondary: Color,
val surface: Color,
val onSurface: Color,
)
@Immutable
data class TextTokens(
val heading: Color,
val body: Color,
val caption: Color,
)
@Immutable
data 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.

Compose Compiler Report - After
ThemedCard: restartable skippable

But then I ran into another issue. I created a composable to access my tokens:

TokenAccessor.kt - First Try
@Composable
fun AppColorTokens(): AppColorTokens = LocalAppTokens.current

This worked, but I was still seeing overhead in my composition traces. A commenter on Reddit suggested using @ReadOnlyComposable instead.

TokenAccessor.kt - Optimized
@Composable
@ReadOnlyComposable
val colors: AppColorTokens get() = LocalAppTokens.current

@ReadOnlyComposable marks a function as read-only. It doesn’t modify state. This gives Compose two benefits:

@ReadOnlyComposable Benefits
1. Thread Safety - Can be called from any thread
2. No State Tracking - Skip recomposition tracking overhead
3. Aggressive Caching - Results can be cached more freely

I learned when to use each annotation. @Immutable is for data classes with only val properties. Types that will never change.

Good @Immutable candidates
@Immutable
data class ThemeColors(
val primary: Color,
val secondary: Color,
val surface: Color,
)
@Immutable
data 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.

Wrong: Mutable state with @Immutable
@Immutable // WRONG!
class CounterState {
var count: Int = 0 // This changes!
}

Use @Stable for mutable but observable state instead:

Correct: @Stable for observable state
@Stable
class CounterState {
var count: Int by mutableStateOf(0)
}

@ReadOnlyComposable is for accessor functions. Functions that only read and return values.

Good @ReadOnlyComposable candidates
@Composable
@ReadOnlyComposable
val themeColors: ColorScheme get() = MaterialTheme.colorScheme
@Composable
@ReadOnlyComposable
val typography: Typography get() = MaterialTheme.typography
@Composable
@ReadOnlyComposable
val colors: AppColorTokens get() = LocalAppTokens.current

Here’s the complete pattern I ended up with for design tokens:

Complete Design Token Pattern
// 1. Immutable data classes
@Immutable
data class BgTokens(
val primary: Color,
val secondary: Color,
val surface: Color,
val onSurface: Color,
)
@Immutable
data class TextTokens(
val heading: Color,
val body: Color,
val caption: Color,
)
@Immutable
data class AppColorTokens(
val bg: BgTokens,
val text: TextTokens,
)
// 2. Composition local provider
val LocalAppTokens = staticCompositionLocalOf<AppColorTokens> {
error("AppTokens not provided")
}
// 3. Read-only accessor
@Composable
@ReadOnlyComposable
val colors: AppColorTokens get() = LocalAppTokens.current
// 4. Usage in composables
@Composable
fun 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.

Compiler Report Key
restartable skippable -> Ideal (can skip recomposition)
restartable -> Needs optimization
(none) -> Not restartable or skippable

I also learned about collections. Compose treats List, Map, Set as unstable by default.

Wrong: Unstable collection
@Immutable
data class UserList(
val users: List<User> // Unstable!
)

I have two options: use kotlinx immutable collections, or wrap in a stable type.

Option 1: Immutable collections
@Immutable
data class UserList(
val users: ImmutableList<User> // from kotlinx.collections.immutable
)
Option 2: Stable wrapper
@Immutable
data class UserList(
private val _users: List<User>
) {
val users: List<User> get() = _users.toList()
}

Here’s a quick reference:

AnnotationWhen to UseEffect
@ImmutableData classes with only valNever changes
@StableClasses with mutable observable stateSafely observable
@ReadOnlyComposableFunctions that only read stateOptimized 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:

Testing immutable tokens
@Test
fun `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:

Oh, and if you found these resources useful, don’t forget to support me by starring the repo on GitHub!

Comments