Don’t Let a Timer Ruin Your Jetpack Compose UI: Best Practices Explained
Jetpack Compose makes building UI faster and more intuitive with a declarative approach. However, one challenge developers face is managing recompositions efficiently, especially when state updates frequently, such as with a timer. In this post, we’ll build a timer component and show how it can unintentionally cause recomposition of unrelated UI elements. We’ll also explore ways to avoid these pitfalls using state hoisting LaunchedEffect
and composition-local best practices.
🧱 The Scenario
You want to show:
- A countdown timer.
- A static profile card.
- A score counter that only updates on user interaction.
Let’s say you build it like this:
@Composable
fun TimerScreen() {
var timer by remember { mutableStateOf(60) }
LaunchedEffect(Unit) {
while (timer > 0) {
delay(1000)
timer--
}
}
Column {
TimerDisplay(timer = timer)
ProfileCard()
ScoreCounter()
}
}
@Composable
fun TimerDisplay(timer: Int) {
Text(text = "Time left: $timer seconds")
}
@Composable
fun ProfileCard() {
// Imagine complex UI
Text(text = "User: Sumeet Panchal")
}
@Composable
fun ScoreCounter() {
var score by remember { mutableStateOf(0) }
Button(onClick = { score++ }) {
Text("Score: $score")
}
}
🚨 The Problem: Unnecessary Recompositions
The issue here is subtle but important.
Every second, timer
state changes, triggering recomposition of everything inside TimerScreen()
:
TimerDisplay()
✅ ExpectedProfileCard()
❌ UnnecessaryScoreCounter()
❌ Unnecessary
This leads to performance overhead, especially if these components are complex or interact with the system.
✅ The Fix: Split State Ownership and Use rememberUpdatedState
To avoid unnecessary recompositions:
- Move the timer logic to a separate component.
- Hoist state only where needed.
- Use
rememberUpdatedState()
orderivedStateOf()
for fine-grained control.
Let’s refactor it:
@Composable
fun TimerScreen() {
Column {
TimerWrapper()
ProfileCard()
ScoreCounter()
}
}
@Composable
fun TimerWrapper() {
var timer by remember { mutableStateOf(60) }
LaunchedEffect(Unit) {
while (timer > 0) {
delay(1000)
timer--
}
}
TimerDisplay(timer = timer)
}
Now, only TimerWrapper
recomposes every second, leaving the rest of the UI untouched.
🧠 Best Practices to Avoid Unnecessary Recompositions
- Split Composables by Responsibility: Smaller composables with isolated state avoid triggering unrelated recompositions.
- Use
derivedStateOf
When State Depends on Another:
val isUrgent by remember { derivedStateOf { timer < 10 } }
- Prefer
LaunchedEffect
For Side Effects: Keep ticking logic outside your root composables to prevent accidental recompositions. - Avoid Passing Changing State Deep into Composables Unless Needed.
- Use
rememberUpdatedState
In Callbacks: Helps avoid stale lambda captures.
🔍 Advanced Tip: Using StateFlow
or ViewModel
If your timer is used across multiple screens or survives configuration changes, consider using a ViewModel
and StateFlow
:
class TimerViewModel : ViewModel() {
private val _timer = MutableStateFlow(60)
val timer: StateFlow<Int> = _timer
init {
viewModelScope.launch {
while (_timer.value > 0) {
delay(1000)
_timer.value -= 1
}
}
}
}
Then collect it in your Composable:
@Composable
fun TimerWrapper(viewModel: TimerViewModel = viewModel()) {
val timer by viewModel.timer.collectAsState()
TimerDisplay(timer)
}
📌 Conclusion
Jetpack Compose is powerful, but with power comes responsibility. In reactive UIs, frequent state changes can quickly snowball into performance issues if not managed carefully. Isolate your state, minimize recompositions, and you’ll enjoy both performance and clean architecture.