The Complete Internals of Jetpack Compose's remember: A Deep Dive
If you’ve worked with Jetpack Compose, you’ve definitely used remember. But have you ever wondered how it actually remembers your values across recompositions? How does Compose know when to recalculate and when to return cached values?
In this deep dive, we’ll dissect the complete mechanism behind remember — from API surface to internal slot table management. By the end, you’ll understand the invisible performance engine powering your Compose apps.
The Problem remember Solves
Before we dive into implementation details, let’s understand why remember exists.
In Compose, your UI is a function of state. When state changes, composables recompose (re-execute). Without remember, every recomposition would recreate objects from scratch:
@Composable
fun Counter() {
// ❌ BAD: Creates new state on every recomposition
var count = mutableStateOf(0)
Button(onClick = { count.value++ }) {
Text(”Count: ${count.value}”)
}
}This doesn’t work because count gets reset to 0 on every recomposition. Enter remember:
@Composable
fun Counter() {
// ✅ GOOD: Survives recomposition
var count = remember { mutableStateOf(0) }
Button(onClick = { count.value++ }) {
Text(”Count: ${count.value}”)
}
}Now the question is: How does remember preserve values across function calls?
Architecture Overview
The remember system consists of four key components:
rememberAPI — Entry point with key trackingchanged()mechanism — Detects when keys have changedcache()core — The actual caching logicSlot Table — Where remembered values are stored
RememberObserver— Lifecycle callbacks for managed objects
Let’s explore each component in detail.
Part 1: The remember API Surface
Looking at the source code, remember comes in multiple overloads:
// No keys - always returns same value
@Composable
inline fun <T> remember(
crossinline calculation: @DisallowComposableCalls () -> T
): T = currentComposer.cache(false, calculation)
// Single key
@Composable
inline fun <T> remember(
key1: Any?,
crossinline calculation: @DisallowComposableCalls () -> T
): T {
return currentComposer.cache(
currentComposer.changed(key1),
calculation
)
}
// Two keys
@Composable
inline fun <T> remember(
key1: Any?,
key2: Any?,
crossinline calculation: @DisallowComposableCalls () -> T
): T {
return currentComposer.cache(
currentComposer.changed(key1) or currentComposer.changed(key2),
calculation
)
}
// Varargs for multiple keys
@Composable
inline fun <T> remember(
vararg keys: Any?,
crossinline calculation: @DisallowComposableCalls () -> T
): T {
var invalid = false
for (key in keys) invalid = invalid or currentComposer.changed(key)
return currentComposer.cache(invalid, calculation)
}Key Observations:
Parameterless version: Passes
falsetocache()— meaning never invalidateKeyed versions: Calculate invalidation by checking if any key changed
Logical OR: If any key changes, the entire cache invalidates
@DisallowComposableCalls: The calculation lambda cannot call other composables
Example Usage:
@Composable
fun UserProfile(userId: String, theme: Theme) {
// Recalculates when either userId OR theme changes
val userViewModel = remember(userId, theme) {
UserViewModel(userId, theme)
}
// ... rest of UI
}Part 2: The changed() Mechanism
When you pass keys to remember, each key goes through currentComposer.changed():
// Simplified conceptual implementation
fun changed(value: Any?): Boolean {
val previous = nextSlot() // Read from slot table
return if (previous != value) {
updateValue(value) // Write new value to slot
true // Signal: value changed
} else {
false // Signal: value unchanged
}
}How It Works:
Read previous slot: Fetch the value stored from last composition
Compare: Use
==(structural equality) to check if changedUpdate if changed: Write new value back to slot table
Return boolean:
trueif changed,falseif same
Important Detail: Structural Equality
Compose uses == for comparison, which means:
data class User(val id: String, val name: String)
@Composable
fun ProfileScreen(user: User) {
// ✅ Detects changes correctly (data class uses structural equality)
val profile = remember(user) {
ProfileData(user)
}
}
class UserRef(val id: String, val name: String)
@Composable
fun ProfileScreen2(user: UserRef) {
// ❌ May not detect changes (reference equality by default)
val profile = remember(user) {
ProfileData(user)
}
}Pro tip: Always use data classes or implement equals() for keys.
Part 3: The cache() Core
After calculating whether keys changed, remember calls currentComposer.cache():
// Conceptual implementation
fun <T> cache(invalid: Boolean, block: () -> T): T {
val currentSlot = nextSlot()
return when {
invalid || currentSlot === EMPTY -> {
// Need to recalculate
val newValue = block()
updateValue(newValue)
newValue
}
else -> {
// Use cached value
currentSlot as T
}
}
}
```
### The Logic Flow:
```
┌─────────────────────┐
│ cache(invalid, λ) │
└──────────┬──────────┘
│
┌──────────▼──────────┐
│ Read current slot │
└──────────┬──────────┘
│
┌────────────────┴────────────────┐
│ │
┌─────────▼─────────┐ ┌──────────▼──────────┐
│ invalid == true │ │ invalid == false │
│ OR slot empty │ │ AND slot exists │
└─────────┬─────────┘ └──────────┬──────────┘
│ │
┌─────────▼─────────┐ ┌──────────▼──────────┐
│ Execute block() │ │ Return cached value │
└─────────┬─────────┘ └─────────────────────┘
│
┌─────────▼─────────┐
│ Store in slot │
│ Return new value │
└───────────────────┘Example Scenario:
@Composable
fun ExpensiveCalculation(userId: String) {
val result = remember(userId) {
// This only runs when userId changes
println(”Computing expensive result...”)
computeExpensiveValue(userId)
}
Text(”Result: $result”)
}
// First composition (userId = “abc”)
// → Slot is empty
// → Execute block, prints “Computing...”
// → Store result in slot
// Recomposition with same userId = “abc”
// → userId hasn’t changed
// → changed() returns false
// → cache() returns stored value
// → Block doesn’t execute
// Recomposition with userId = “xyz”
// → userId changed
// → changed() returns true
// → cache() executes block again
// → Prints “Computing...” and stores new result
```
---
## Part 4: The Slot Table - Where Memory Lives
The slot table is Compose’s internal data structure for storing remembered values. Think of it as a positional array indexed by the composable’s position in the tree.
### Conceptual Structure:
```
Composition Tree Slot Table
───────────────── ────────────────────────────
MyScreen() [Slot 0: ScreenState]
├─ remember { state } →
├─ Column() [Slot 1: ColumnNode]
│ ├─ Text(”Hello”) [Slot 2: “Hello”]
│ └─ remember { vm } → [Slot 3: ViewModel]
└─ Button() [Slot 4: ButtonNode]
└─ remember { cnt } → [Slot 5: MutableState(0)]Key Properties:
Positional: Each composable has a fixed slot based on call site
Reused: Slots persist across recompositions
Reset on removal: If a composable leaves composition, its slots are cleared
Example: Position Matters
@Composable
fun ConditionalRemember(showFirst: Boolean) {
if (showFirst) {
val a = remember { “First” } // Uses Slot 0
Text(a)
} else {
val b = remember { “Second” } // Also uses Slot 0!
Text(b)
}
}
// When showFirst toggles:
// - The slot is reused for different remember calls
// - Values don’t “leak” between branches
// - Each branch gets fresh calculation on first appearanceThis is why key() is important in loops — to stabilize slot positions:
@Composable
fun UserList(users: List<User>) {
users.forEach { user ->
key(user.id) { // Stabilize slot position by ID
val state = remember { UserState(user) }
UserCard(state)
}
}
}Part 5: RememberObserver - Lifecycle Hooks
Some remembered objects need to manage resources (like ViewModels, coroutines, animations). Compose provides the RememberObserver interface:
interface RememberObserver {
fun onRemembered() // Called when entering composition
fun onForgotten() // Called when leaving composition
fun onAbandoned() // Called if composition is cancelled
}Implementation Example:
class ResourceManager : RememberObserver {
private var resource: Resource? = null
override fun onRemembered() {
println(”ResourceManager entered composition”)
resource = acquireResource()
}
override fun onForgotten() {
println(”ResourceManager left composition”)
resource?.release()
resource = null
}
override fun onAbandoned() {
println(”Composition was cancelled”)
resource?.release()
resource = null
}
}
@Composable
fun MyScreen() {
val manager = remember { ResourceManager() }
// manager.onRemembered() called automatically
// ... when MyScreen leaves composition:
// manager.onForgotten() called automatically
}Real-World Example: DisposableEffect
The built-in DisposableEffect uses RememberObserver internally:
@Composable
fun LocationTracker() {
DisposableEffect(Unit) {
val listener = startLocationUpdates()
onDispose {
listener.stop() // Cleanup when leaving composition
}
}
}Complete Flow Diagram
Here’s the full journey of a remember call:
Advanced Patterns and Best Practices
1. Derived State with remember
Instead of storing redundant state, derive it:
@Composable
fun FilteredList(items: List<Item>, query: String) {
// ❌ BAD: Stores derived state
var filteredItems by remember { mutableStateOf(emptyList<Item>()) }
LaunchedEffect(items, query) {
filteredItems = items.filter { it.matches(query) }
}
// ✅ GOOD: Derives on-demand
val filteredItems = remember(items, query) {
items.filter { it.matches(query) }
}
}2. Heavy Object Creation
Use remember for expensive instantiations:
@Composable
fun ChartScreen(data: ChartData) {
// Reuses renderer across recompositions
val renderer = remember { ChartRenderer() }
// Updates renderer when data changes
LaunchedEffect(data) {
renderer.updateData(data)
}
AndroidView(factory = { renderer.view })
}3. Avoid Over-Keying
Don’t include keys that don’t affect the calculation:
@Composable
fun UserProfile(userId: String, timestamp: Long) {
// ❌ BAD: Recalculates on every timestamp change
val viewModel = remember(userId, timestamp) {
UserViewModel(userId)
}
// ✅ GOOD: Only recalculates when userId changes
val viewModel = remember(userId) {
UserViewModel(userId)
}
}4. Stable Keys
Ensure keys are structurally comparable:
data class UserId(val value: String) // ✅ Data class
class UserIdRef(val value: String) // ❌ Reference equality
@Composable
fun ProfileScreen(userId: UserId) {
// Works correctly because UserId is a data class
val profile = remember(userId) { fetchProfile(userId) }
}Performance Implications
The Cost of remember
Slot table lookup: O(1) - very fast
Key comparison: O(n) where n = number of keys
Calculation: Only runs when invalid
Benchmark Example:
@Composable
fun PerformanceTest() {
// Scenario 1: No keys (fastest)
val a = remember { heavyComputation() }
// Cost: Just slot lookup
// Scenario 2: One key
val b = remember(key) { heavyComputation() }
// Cost: Slot lookup + 1 comparison
// Scenario 3: Many keys
val c = remember(k1, k2, k3, k4, k5) { heavyComputation() }
// Cost: Slot lookup + 5 comparisons
}Pro tip: Keep key counts reasonable (1-3 keys is typical).
Common Pitfalls
1. Capturing Unstable Values
@Composable
fun BadExample(user: User) {
// ❌ Captures unstable lambda
val callback = remember {
{ processUser(user) } // ‘user’ is captured from outer scope!
}
// ✅ Include user as key
val callback = remember(user) {
{ processUser(user) }
}
}2. Forgetting to Key Expensive Calculations
@Composable
fun ExpensiveUI(data: Data) {
// ❌ Recalculates on every recomposition
val processed = processData(data)
// ✅ Only recalculates when data changes
val processed = remember(data) {
processData(data)
}
}3. Using Mutable Collections as Keys
@Composable
fun ListScreen(items: MutableList<Item>) {
// ❌ MutableList reference stays same even when contents change
val processed = remember(items) {
processItems(items)
}
// ✅ Convert to immutable or use a different key
val processed = remember(items.toList()) {
processItems(items)
}
// Or better: use a version counter
val processed = remember(items.size, items.hashCode()) {
processItems(items)
}
}Debugging remember
Trace Key Changes
@Composable
fun DebugRemember(key: Any?) {
val value = remember(key) {
println(”🔄 Recalculating because key changed: $key”)
expensiveCalculation()
}
println(”✅ Using value: $value for key: $key”)
}Compose Compiler Reports
Enable Compose compiler metrics to see skip rates:
kotlinOptions {
freeCompilerArgs += [
“-P”, “plugin:androidx.compose.compiler.plugins.kotlin:metricsDestination=” +
project.buildDir.absolutePath + “/compose_metrics”
]
}This generates reports showing which composables skip recomposition and why.
Conclusion
The remember mechanism is a masterclass in efficient state management:
API simplicity: Clean, intuitive interface
Smart invalidation: Only recalculates when necessary
Positional caching: Slot table provides O(1) lookups
Lifecycle integration:
RememberObserverfor resource managementPerformance: Minimal overhead for maximum efficiency
By understanding these internals, you can:
Write more performant Compose code
Debug state issues effectively
Make informed decisions about state management
Appreciate the engineering behind Compose’s “smart” recomposition
The next time you write remember { ... }, you’ll know exactly what’s happening under the hood — from key comparison to slot table storage to lifecycle callbacks.
Further Reading
Have questions about Compose internals? Found this deep dive helpful? Let me know in the comments! And if you want more Android engineering insights, follow me for weekly technical breakdowns. 🚀



