LazyColumn Performance Optimization — key, contentType & Recomposition Control

LazyColumn Performance Optimization — key, contentType & Recomposition Control

Optimize LazyColumn rendering in Jetpack Compose. Master key parameter for diff updates, contentType for ViewHolder recycling, stability markers, lambda caching, and derivedStateOf for scroll-aware UI. Includes performance checklist.

1. The key Parameter — Why It Matters

Without explicit keys, Compose uses list indices. Indices change when items are added/removed, causing unnecessary recompositions and animation bugs.

// ❌ BAD: No keys — indices shift
LazyColumn {
    items(items.size) { index ->
        ItemCard(items[index])
    }
}

// ✅ GOOD: Unique keys
LazyColumn {
    items(
        count = items.size,
        key = { index -> items[index].id }  // Stable, unique key
    ) { index ->
        ItemCard(items[index])
    }
}

// ✅ BETTER: Using extension
LazyColumn {
    items(
        items = items,
        key = { it.id }  // Each item has unique id
    ) { item ->
        ItemCard(item)
    }
}

Impact: Without keys, 100-item list deletion causes 99 unnecessary recompositions. With keys: only 1 recomposition (deletion).

2. contentType for Recycling

ViewHolder-like recycling: group items by type to reuse composition slots.

enum class ItemType { Header, Content, Footer, Ad }

data class ListItem(
    val id: String,
    val type: ItemType,
    val title: String,
    val body: String? = null
)

@Composable
fun OptimizedList(items: List<ListItem>) {
    LazyColumn {
        items(
            items = items,
            key = { it.id },
            contentType = { it.type }  // ← Recycling hint
        ) { item ->
            when (item.type) {
                ItemType.Header -> HeaderItem(item.title)
                ItemType.Content -> ContentItem(item.title, item.body!!)
                ItemType.Footer -> FooterItem(item.title)
                ItemType.Ad -> AdBanner()
            }
        }
    }
}

Benefit: Compose reuses composition slots for same type, reducing layout thrashing.

3. @stable & @Immutable Markers

Prevent unnecessary recompositions by marking immutable data classes.

@Immutable
data class User(
    val id: String,
    val name: String,
    val avatar: String
)

@Stable
class UserViewModel {
    private val _selectedUser = mutableStateOf<User?>(null)
    val selectedUser: State<User?> = _selectedUser
}

// Without markers, Compose can't guarantee stability → extra recompositions
// With markers, Compose trusts data hasn't changed

4. Lambda Caching with remember

Callbacks passed to items should be memoized to prevent recompositions.

@Composable
fun UserListScreen(viewModel: UserViewModel) {
    // ❌ BAD: New lambda every composition
    LazyColumn {
        items(viewModel.users) { user ->
            UserCard(
                user = user,
                onDelete = { viewModel.deleteUser(user.id) }  // ← New lambda each time
            )
        }
    }

    // ✅ GOOD: Memoized callback
    val onDeleteCallback = remember {
        { userId: String -> viewModel.deleteUser(userId) }
    }

    LazyColumn {
        items(viewModel.users) { user ->
            UserCard(user = user, onDelete = { onDeleteCallback(user.id) })
        }
    }
}

5. derivedStateOf for Scroll-Aware UI

Detect scroll position changes without triggering full list recompositions.

@Composable
fun ScrollAwareList(items: List<String>) {
    val lazyListState = rememberLazyListState()

    // ✅ Derived state: only recomposes when result changes
    val isScrolled by remember {
        derivedStateOf {
            lazyListState.firstVisibleItemIndex > 0 ||
            lazyListState.firstVisibleItemScrollOffset > 0
        }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Scroll Aware") },
                elevation = if (isScrolled) 4.dp else 0.dp  // ← Conditional elevation
            )
        }
    ) { paddingValues ->
        LazyColumn(
            state = lazyListState,
            modifier = Modifier.padding(paddingValues)
        ) {
            items(items.size, key = { items[it].hashCode() }) { index ->
                Text(items[index], modifier = Modifier.padding(16.dp))
            }
        }
    }
}

Without derivedStateOf: Scroll → lazyListState change → full list recomposition.
With derivedStateOf: Scroll → derive Boolean → only TopAppBar recomposes.

6. Performance Checklist

Issue Solution Impact
No keys Add key = { it.id } Huge: prevents index-shift recompositions
No contentType Add contentType = { it.type } Medium: improves recycling
Mutable data Use @Immutable/@stable Medium: helps compiler optimize
New lambdas Memoize with remember Low-Medium: prevents child recompositions
Scroll state propagation Use derivedStateOf High: isolates scroll logic
Large lists (>500 items) Add both keys + contentType Critical
Complex item UI Extract to separate @Composable Medium: enables skipping
State hoisted wrong Move state up correctly High: prevents recomposition leaks

7. Complete Example: Optimized User List

@Immutable
data class UserItem(val id: String, val name: String, val email: String)

@Composable
fun OptimizedUserList(
    users: List<UserItem>,
    onUserClick: (String) -> Unit
) {
    val lazyListState = rememberLazyListState()
    val isScrolled by remember {
        derivedStateOf { lazyListState.firstVisibleItemIndex > 0 }
    }

    val handleClick = remember {
        { userId: String -> onUserClick(userId) }
    }

    Scaffold(
        topBar = {
            TopAppBar(
                title = { Text("Users") },
                elevation = if (isScrolled) 4.dp else 0.dp
            )
        }
    ) { paddingValues ->
        LazyColumn(
            state = lazyListState,
            modifier = Modifier.padding(paddingValues)
        ) {
            items(
                items = users,
                key = { it.id },
                contentType = { "user" }
            ) { user ->
                UserCard(
                    user = user,
                    onClick = { handleClick(user.id) }
                )
            }
        }
    }
}

@Composable
fun UserCard(user: UserItem, onClick: () -> Unit) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(8.dp)
            .clickable(onClick = onClick)
    ) {
        Column(modifier = Modifier.padding(16.dp)) {
            Text(user.name, style = MaterialTheme.typography.bodyLarge)
            Text(user.email, style = MaterialTheme.typography.bodySmall)
        }
    }
}

Summary

LazyColumn performance hinges on:

  1. key — Prevents index-shift recompositions (critical)
  2. contentType — Enables ViewHolder-like recycling
  3. @stable/@Immutable — Compiler optimization hints
  4. remember lambdas — Prevent callback churn
  5. derivedStateOf — Isolate scroll/selection state
  6. Extract composables — Enable skipping

For lists >500 items, use all techniques. For <50 items, keys + contentType suffice.

8 Android app templates: Gumroad

Leave a Reply