Fixing Violations
Fixing Violations
Section titled “Fixing Violations”This guide covers common violation patterns organized by budget class, with before/after code examples for each.
SCREEN violations (budget: 3/s)
Section titled “SCREEN violations (budget: 3/s)”Screen composables should recompose rarely — only on navigation or major state changes. A SCREEN violation almost always means state is being read too broadly.
Problem: Reading multiple state flows at the screen level
Section titled “Problem: Reading multiple state flows at the screen level”Before:
@Composablefun HomeScreen(viewModel: HomeViewModel) { val user by viewModel.user.collectAsState() val feed by viewModel.feed.collectAsState() val notifications by viewModel.notifications.collectAsState()
// HomeScreen recomposes whenever ANY of these three states change Scaffold( topBar = { TopBar(user = user, notificationCount = notifications.size) }, content = { FeedList(feed = feed) } )}After: Push state reads down to the composables that actually use them:
@Composablefun HomeScreen(viewModel: HomeViewModel) { Scaffold( topBar = { TopBarWrapper(viewModel) }, content = { FeedListWrapper(viewModel) } )}
@Composableprivate fun TopBarWrapper(viewModel: HomeViewModel) { val user by viewModel.user.collectAsState() val notifications by viewModel.notifications.collectAsState() TopBar(user = user, notificationCount = notifications.size)}
@Composableprivate fun FeedListWrapper(viewModel: HomeViewModel) { val feed by viewModel.feed.collectAsState() FeedList(feed = feed)}Now HomeScreen itself only recomposes on navigation changes. Each wrapper only recomposes when its own state changes.
Problem: Unstable ViewModel parameter
Section titled “Problem: Unstable ViewModel parameter”If you pass a ViewModel directly as a parameter, it is inherently unstable. The Compose compiler cannot prove that a ViewModel instance is equal across recompositions.
Fix: Either annotate with @Stable if the ViewModel is genuinely stable, or restructure to pass only the state values the composable needs.
CONTAINER violations (budget: 8/s)
Section titled “CONTAINER violations (budget: 8/s)”Containers (Column, Row, Box, Card, Surface) sit between screens and leaves. A CONTAINER violation usually means a child is invalidating the parent.
Problem: Child state read in parent scope
Section titled “Problem: Child state read in parent scope”Before:
@Composablefun UserCard(userId: String, repository: UserRepository) { val user by repository.getUser(userId).collectAsState(initial = null)
Card { if (user != null) { Avatar(url = user!!.avatarUrl) Text(user!!.name) OnlineIndicator(isOnline = user!!.isOnline) // Changes every 5 seconds } }}The isOnline field changes every 5 seconds. Because user is read at the Card level, the entire card recomposes — including Avatar and Text that have not changed.
After: Isolate the frequently changing part:
@Composablefun UserCard(userId: String, repository: UserRepository) { val user by repository.getUser(userId).collectAsState(initial = null)
Card { if (user != null) { Avatar(url = user!!.avatarUrl) Text(user!!.name) OnlineStatus(userId = userId, repository = repository) } }}
@Composableprivate fun OnlineStatus(userId: String, repository: UserRepository) { val isOnline by repository.getOnlineStatus(userId).collectAsState(initial = false) OnlineIndicator(isOnline = isOnline)}Now the card only recomposes when the user’s name or avatar changes. The online indicator recomposes independently.
Problem: Key missing in LazyColumn
Section titled “Problem: Key missing in LazyColumn”LazyColumn { items(users) { user -> UserCard(user = user) // No key -- recomposes all items on list change }}Fix: Always provide a stable key:
LazyColumn { items(users, key = { it.id }) { user -> UserCard(user = user) }}LEAF violations (budget: 5/s)
Section titled “LEAF violations (budget: 5/s)”Leaf composables (Text, Icon, Image, custom composables with no children) have tight budgets because they should rarely recompose. A LEAF violation often means upstream state is pushing updates too frequently.
Problem: Continuous sensor or timer updates
Section titled “Problem: Continuous sensor or timer updates”Before:
@Composablefun TemperatureDisplay(sensorFlow: Flow<Float>) { val temperature by sensorFlow.collectAsState(initial = 0f) Text("${temperature}C") // Updates 30 times per second from sensor}After: Debounce the upstream state:
@Composablefun TemperatureDisplay(sensorFlow: Flow<Float>) { val temperature by remember(sensorFlow) { sensorFlow .distinctUntilChanged { old, new -> abs(old - new) < 0.5f } .debounce(200) }.collectAsState(initial = 0f)
Text("${temperature}C") // Updates only on meaningful changes}Problem: Formatting in recomposition scope
Section titled “Problem: Formatting in recomposition scope”Before:
@Composablefun PriceTag(amount: Double) { val formatted = NumberFormat.getCurrencyInstance().format(amount) // New object every recomposition Text(formatted)}After: Remember the formatted result:
@Composablefun PriceTag(amount: Double) { val formatted = remember(amount) { NumberFormat.getCurrencyInstance().format(amount) } Text(formatted)}ANIMATED misclassification (budget: 120/s)
Section titled “ANIMATED misclassification (budget: 120/s)”Sometimes a composable that is driven by animation is not classified as ANIMATED because its name does not match animation patterns. It triggers violations against a lower budget.
Problem: Custom animation composable not recognized
Section titled “Problem: Custom animation composable not recognized”Before:
@Composablefun PulsingDot(progress: Float) { // Classified as LEAF (5/s), actual rate: 60/s val scale = lerp(0.8f, 1.2f, progress) Box( Modifier .size(12.dp) .scale(scale) .background(Color.Red, CircleShape) )}After: Override the budget with @ReboundBudget:
@ReboundBudget(BudgetClass.ANIMATED)@Composablefun PulsingDot(progress: Float) { // Now classified as ANIMATED (120/s) val scale = lerp(0.8f, 1.2f, progress) Box( Modifier .size(12.dp) .scale(scale) .background(Color.Red, CircleShape) )}The @ReboundBudget annotation tells the compiler plugin to skip heuristic classification and use the specified budget class directly.
When to override vs. when to fix
Section titled “When to override vs. when to fix”- Override when the composable genuinely needs a high recomposition rate (animation, gesture tracking, physics simulation).
- Fix when the high rate is caused by a bug (unstable parameter, missing
remember, broad state read).
If you are unsure, check the Stability tab. If parameters are marked DIFFERENT on every recomposition, the problem is likely an unstable type — fix the type rather than raising the budget.
General principles
Section titled “General principles”- Read state as close to where it is used as possible. The higher in the tree you read state, the larger the subtree that recomposes.
- Use stable types. Prefer
ImmutableList,ImmutableMapfromkotlinx.collections.immutable. AvoidList,Map,Setas parameters to composables. - Provide keys in lazy layouts. Without keys, the entire list recomposes on any change.
- Debounce high-frequency sources. Sensors, timers, and network polling should not push raw updates into composition.
- Use
derivedStateOffor computed values. If a composable depends on a transformation of state, wrap it inderivedStateOfto avoid recomposition when the derived value has not changed.
val showButton by remember { derivedStateOf { scrollState.firstVisibleItemIndex > 0 }}