Optimizing Conditional Rendering in Jetpack Compose: Keep It Simple or Keep It Together?
Hey there 👋 As someone who’s been digging deeper into Jetpack Compose, I’ve been looking at how to make UI code more efficient, maintainable, and straightforward. One topic that comes up a lot is conditional rendering — deciding when and how to show certain parts of your UI based on conditions. Recently, I ran into a convo about this, where one side insisted on keeping the logic tightly coupled in a single place, especially when the condition comes from the ViewModel’s ViewState.
I thought I’d share my perspective on whether to conditionally invoke a composable function or always invoke it and handle the visibility logic inside it. To back my reasoning, I’ll also mention how Compose internally handles certain things, which might help justify why one approach can be cleaner and more performant. And importantly, I’ll explain why trying to keep logic and rendering tightly coupled might not be the best idea in this context, and how following principles like YAGNI can help avoid unnecessary complexity.
Understanding Conditional Rendering and Compose Internals
In Jetpack Compose, every composable you call participates in a composition. The composition is where Compose figures out what to show on screen. When you wrap conditionals around composables, you’re effectively controlling whether something enters the composition tree at all. Because Compose tracks changes at a granular level, omitting a composable entirely when a condition is false means Compose doesn’t have to do any extra work updating or maintaining that part of the UI.
Internally, Compose uses a smart mechanism called “recomposition” to only re-render parts of the UI that have changed. If a composable isn’t in the tree, it’s simply not managed, measured, or laid out. This leads to fewer nodes in the composition, less overhead in recompositions, and a more direct mapping between state and UI.
Here’s a bit more detail on overhead: During recomposition, Compose references a key internal data structure (the slot table) that records information about each composable. When there are extra composables in the tree that don’t actually produce UI (because their condition is false internally), Compose still has to account for them. Even if they do nothing, Compose must consider their slots, their state, and confirm that no updates are needed. This adds overhead — albeit sometimes small, it’s still an unnecessary step. By excluding composables entirely when conditions are not met, the slot table stays leaner and recompositions remain more efficient, since there are fewer irrelevant nodes to process.
Option 1: Conditionally Invoke the Composable
The Idea
You only call the composable function when the condition is met. If the condition is false, you just don’t include it in the composition. Compose then has no node for that composable in its hierarchy, meaning less internal work and more direct state-to-UI mapping.
Why This Matters Internally
Compose loves it when the UI hierarchy directly mirrors what’s visible on screen. If a piece of UI shouldn’t show up, don’t put it in the tree. This reduces the complexity of the composition, makes recomposition checks simpler, and can improve performance since Compose won’t spend cycles considering nodes that aren’t actually needed.
Example
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
val showColumn by viewModel.showColumn.observeAsState(initial = true)
Column {
Button(onClick = { viewModel.toggleColumnVisibility() }) {
Text(text = if (showColumn) "Hide Column" else "Show Column")
}
// Conditionally invoke the composable only if needed
if (showColumn) {
ConditionalColumn()
}
}
}
@Composable
fun ConditionalColumn() {
// This only gets composed when needed, simplifying the composition hierarchy.
Column(
modifier = Modifier
.background(Color.LightGray)
.padding(16.dp)
) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
}
When showColumn
is false, there’s no ConditionalColumn
in the tree. Internally, Compose’s snapshot system and composition phases don’t have to consider it at all, keeping things lean and mean.
Option 2: Always Call the Composable and Handle Conditions Inside
The Idea
In this approach, you always invoke the composable, even if the condition is false. The composable then decides what to show internally. This can feel more “coupled” since the composable knows about the condition, and you might think it keeps logic in one place.
Internal Trade-Offs
From Compose’s point of view, this composable always exists in the composition, even when it’s effectively doing nothing. It’s still a node in the tree, still something Compose has to consider during recompositions. Even if the conditional logic inside results in no UI being shown, Compose doesn’t inherently know that’s a no-op; it just knows this composable is part of the hierarchy.
This adds unnecessary overhead, including the slot table checks mentioned earlier. The UI structure is less transparent because everything is always there, just sometimes empty. Internally, this can mean Compose has more nodes to track, which might not kill performance, but it’s certainly not as optimal as leaving the composable out entirely when it’s not needed.
Example
@Composable
fun MainScreen(viewModel: MainViewModel = viewModel()) {
val showColumn by viewModel.showColumn.observeAsState(initial = true)
Column {
Button(onClick = { viewModel.toggleColumnVisibility() }) {
Text(text = if (showColumn) "Hide Column" else "Show Column")
}
// Always invoke, pass the condition, let it decide inside
ConditionalColumn(show = showColumn)
}
}
@Composable
fun ConditionalColumn(show: Boolean) {
if (show) {
Column(
modifier = Modifier
.background(Color.LightGray)
.padding(16.dp)
) {
Text("Item 1")
Text("Item 2")
Text("Item 3")
}
}
// When not visible, still part of the composition,
// just empty. Compose internally still considers it a node.
}
The Coupling Argument and Why YAGNI Is Better
One reason people might want to keep conditions inside a composable is the idea of coupling — they think it’s neater to have all logic in one place to avoid repeating if
statements. While this is understandable, it’s not always a good idea here.
Compose’s internal model is built around the UI tree directly reflecting the current state. Introducing extra nodes that sometimes do nothing doesn’t simplify logic; it adds invisible complexity. By coupling conditions inside the composable, you’re forcing Compose to maintain nodes that don’t need to be there, which can lead to subtle performance overhead and reduce clarity.
Applying the YAGNI principle helps here. Unless you have a concrete, immediate reason to tightly couple conditions inside the composable, don’t do it. YAGNI suggests we shouldn’t add complexities we don’t currently need. Start simple: conditionally invoke the composable only when required. If the day comes that you truly need that internal logic, you can add it then. Until that happens, keep it straightforward and let Compose’s internals work their magic.
Some Tips
- Use
AnimatedVisibility
: If you want nice transitions without cluttering logic,AnimatedVisibility
can bridge the gap. It still respects your choice to include or exclude nodes based on conditions, giving you animation without forcing nodes to always exist. - Keep State Handling in the Parent: Let the parent decide which composables appear. This aligns perfectly with Compose’s philosophy: the state (from the ViewModel) tells us what to render, and we reflect that directly in the composition. No extra coupling is required.
- YAGNI Principle: Don’t complicate your composition with internal checks if there’s no real need. If in the future you find you truly need that complexity, you can introduce it then. Until then, trust Compose’s model and keep it simple.
- Optimize Recomposition Scope: By controlling which composables enter the tree, you naturally minimize the scope of recompositions. Compose will recompose only what’s present and affected, rather than considering empty placeholders.
My Verdict
If your composable doesn’t need to maintain its own internal state or perform complex logic when hidden, just conditionally invoke it. This keeps the composition tree pure and directly reflective of the current UI state, which is how Compose is designed to work best. It’s simpler, more transparent, and plays nicely with Compose’s internal optimizations and slot table management.
If you have a real, immediate need for the composable to always exist and handle its own conditions, then sure, go for it. Just remember you might be adding unnecessary complexity and overhead, and you might not even need that complexity right now. Embrace YAGNI — don’t add complexity before it’s required.
Final Thoughts
In the end, Compose is designed for a direct mapping between state and UI. Unnecessary coupling and internal conditions inside composables can blur that mapping. By avoiding that complexity and sticking to conditional invocation unless you genuinely need otherwise, you keep your codebase cleaner, your UI more transparent, and your performance better aligned with Compose’s internals.
Opt for the straightforward approach first — conditionally invoke the composable when you actually need it. If a future scenario demands more coupling, introduce it then. Until that moment, trust Compose’s design and YAGNI: keep it simple, keep it efficient, and let Compose do what it does best.