This post is based on a talk I gave at DevFest Bangladesh 2022, organised by GDG Sonargaon.
Jetpack Compose has changed how I build Android UIs. After building apps with the old View system for years, switching to Compose felt like removing scaffolding I'd always assumed was load-bearing. Here's what I covered at the event, and what I think every Android developer should know about Compose.
Why Compose Changes Things
The View system was built for Java in 2008. XML layouts, View binding, ViewHolders, adapters — none of these exist in Compose.
Compose is declarative: you describe what the UI should look like given the current state, and Compose handles the rendering. When state changes, the affected parts of the UI recompose automatically.
// Old way — imperative
binding.userName.text = user.name
binding.userAvatar.load(user.avatarUrl)
// Compose way — declarative
@Composable
fun UserCard(user: User) {
Row {
AsyncImage(model = user.avatarUrl, contentDescription = null)
Text(text = user.name)
}
}The UserCard Composable will automatically re-render whenever user changes. No manual binding, no notifyDataSetChanged().
Understanding Recomposition
Compose redraws parts of the UI when their inputs change. This is called recomposition. Understanding it is the most important thing for writing performant Compose code.
@Composable
fun Counter() {
var count by remember { mutableStateOf(0) }
Column {
// Only this Text recomposes when count changes
Text(text = "Count: $count")
// This button never recomposes (its inputs don't change)
Button(onClick = { count++ }) {
Text("Increment")
}
}
}remember keeps state alive across recompositions. Without it, state resets every time the Composable redraws.
State Management in Compose
State flows down, events flow up. This is called unidirectional data flow and it's central to how Compose works.
// Stateful parent — owns the state
@Composable
fun TaskScreen(viewModel: TaskViewModel = hiltViewModel()) {
val tasks by viewModel.tasks.collectAsState()
TaskList(
tasks = tasks,
onTaskComplete = viewModel::completeTask // event goes up
)
}
// Stateless child — renders state it receives
@Composable
fun TaskList(
tasks: List<Task>,
onTaskComplete: (String) -> Unit
) {
LazyColumn {
items(tasks, key = { it.id }) { task ->
TaskItem(task = task, onComplete = { onTaskComplete(task.id) })
}
}
}TaskList has no state — it's a pure function of its inputs. This makes it trivially reusable and testable.
The Most Important Composables to Know
LazyColumn / LazyRow — replaces RecyclerView. Only composes visible items.
LazyColumn {
items(items = myList, key = { it.id }) { item ->
ListItem(item)
}
}Always provide a key — it helps Compose track items across recompositions and animate correctly.
Scaffold — provides the standard Material layout (top bar, bottom bar, FAB, snackbar host):
Scaffold(
topBar = { TopAppBar(title = { Text("Tasks") }) },
floatingActionButton = {
FloatingActionButton(onClick = onAdd) { Icon(Icons.Default.Add, null) }
}
) { padding ->
TaskList(modifier = Modifier.padding(padding))
}AnimatedVisibility / AnimatedContent — smooth state-driven animations with no ObjectAnimator:
AnimatedVisibility(visible = isLoading) {
CircularProgressIndicator()
}Side Effects in Compose
Composables should be side-effect free — their only job is to produce UI. For side effects (launching a coroutine, registering a callback), use effect handlers:
@Composable
fun TrackingScreen(userId: String) {
val scope = rememberCoroutineScope()
// Runs once when userId changes
LaunchedEffect(userId) {
analytics.trackScreen("tracking", userId)
}
// Runs on composition and on cleanup
DisposableEffect(Unit) {
val listener = locationManager.addListener { ... }
onDispose { locationManager.removeListener(listener) }
}
}LaunchedEffect — for one-off coroutine work tied to a key.
DisposableEffect — for anything that needs cleanup (listeners, subscriptions).
SideEffect — for synchronising Compose state to non-Compose code on every successful recomposition.
Migrating from the View System
You don't need to rewrite everything at once. Compose and the View system interoperate:
// Use a Composable inside an existing XML layout
class MyFragment : Fragment() {
override fun onCreateView(...) = ComposeView(requireContext()).apply {
setContent {
MyTheme {
MyComposable()
}
}
}
}
// Use an existing View inside a Composable
AndroidView(factory = { context ->
MapView(context).apply { /* setup */ }
})Start with new screens in Compose. Migrate existing screens incrementally when they need significant changes.
Common Mistakes to Avoid
Reading state outside of Compose. State reads inside a Composable trigger recomposition. Reads outside (in callbacks, coroutines) don't.
Creating state in the wrong place. State that needs to survive recomposition goes in remember. State that needs to survive navigation goes in ViewModel.
Not using key in lazy lists. Without keys, Compose can't track list item identity across changes — leading to incorrect animations and unnecessary recompositions.
Skipping theming. Wrap your app in a MaterialTheme. Composables like Surface, Card, and Button read colour and typography from the theme automatically.
The Learning Curve
Compose is different enough from the View system that it takes real time to click. The first week I used it I was slower than with XML. By the second month I was faster. By the third month I didn't want to go back.
The investment is worth it. The boilerplate is gone, the testing is cleaner, and the UI code finally reads like what it does.