Jetpack Compose Basics for Students

Learn Jetpack Compose from zero: the declarative UI mental model (UI as a function of state), composable function rules, state management with remember and hoisting, Column/Row/Box/LazyColumn layouts, Material 3 theming, navigation, and the mistakes every beginner makes.

Written by Projectech5 min readPublished
For B.E./B.Tech Computer Science and IT students building Android apps for academic and final-year projects Topics: Kotlin, Jetpack Compose, Android
Illustration of a smartphone showing a Jetpack Compose UI with composable function blocks and state flow arrows connecting them.
Illustration generated for this guide.
In this guide

If you learned Android the old way — XML layouts, findViewById, adapters, fragments nested in fragments — Jetpack Compose feels like a different platform. It is: instead of describing a static view hierarchy in XML and mutating it imperatively, you write Kotlin functions that describe what the UI should look like for the current state, and Compose handles updating the screen when the state changes. Less boilerplate, fewer crashes from stale view references, and UI code that reads like the UI it produces.

This guide covers the mental model (declarative UI, state, recomposition), the core building blocks, layouts, Material 3 theming, and the mistakes every Compose beginner makes.

The mental model: UI as a function of state

The entire framework reduces to one idea:

UI = f(state)

You don't "update a TextView." You hold a state variable (say, count), and your composable function declares "show the value of count." When count changes, Compose automatically re-runs the relevant functions and updates the screen. This re-running is called recomposition, and understanding it is 80% of learning Compose.

Compare the old and new approaches for a counter screen:

// Old: XML layout + imperative updates
// activity_counter.xml declares a TextView and Button...
// CounterActivity.kt:
//   findViewById<TextView>(R.id.countText).text = count.toString()
//   // Every state change needs a manual view update, everywhere.

// Compose: declare UI from state
@Composable
fun CounterScreen() {
    var count by remember { mutableStateOf(0) }
    Column {
        Text(text = "Count: " + count)
        Button(onClick = { count++ }) {
            Text("Increment")
        }
    }
}

No view IDs, no manual updates. The count++ changes state; Compose recomposes and the text updates. If you're choosing between Java and Kotlin for Android first, read Kotlin vs Java for Android — Compose requires Kotlin.

Composable functions: the rules

A @Composable function follows a few strict rules:

  1. It's a description, not a command. It can run many times (recomposition) — so it must be free of side effects like network calls or writing to databases. Side effects go in LaunchedEffect or event handlers.
  2. Order doesn't matter; parameters do. Compose identifies UI elements by their call position and parameters. Calling the same composable with different parameters produces different UI.
  3. They're fast and restartable. Compose may skip, reorder, or run composables in parallel. Never assume a composable runs exactly once.
  4. Naming convention: composable functions are PascalCase (like CounterScreen) and return Unit.

State: remember, mutableStateOf, and hoisting

remember { mutableStateOf(0) } creates state that survives recomposition — without remember, the value would reset to 0 on every recomposition. The by delegate lets you read/write it like a plain variable.

As screens grow, hoist state upward: keep the state in the parent (or ViewModel) and pass values down plus event callbacks:

@Composable
fun CounterScreen(viewModel: CounterViewModel = viewModel()) {
    val count by viewModel.count.collectAsState()
    CounterContent(count = count, onIncrement = { viewModel.increment() })
}

@Composable
fun CounterContent(count: Int, onIncrement: () -> Unit) {
    Column {
        Text(text = "Count: " + count)
        Button(onClick = onIncrement) { Text("Increment") }
    }
}

CounterContent is now stateless — easier to preview, test, and reuse. State hoisting is the single habit that keeps Compose codebases maintainable, and it maps directly onto MVVM architecture (see MVVM Architecture for Android).

State holders cheat-sheet:

Holder Survives Use for
remember Recomposition UI-local state (text field input, toggles)
rememberSaveable Recomposition + rotation/process death UI state the user would miss losing
ViewModel + StateFlow Configuration changes, shared across screens Business state, repository data

Layouts: Column, Row, Box, LazyColumn

Compose layouts are just composables that arrange their children:

Column(modifier = Modifier.fillMaxSize().padding(16.dp)) {
    Text("Header", style = MaterialTheme.typography.headlineMedium)
    Row(horizontalArrangement = Arrangement.SpaceBetween) {
        Text("Left")
        Text("Right")
    }
    Box(modifier = Modifier.fillMaxWidth().height(120.dp)) {
        Text("Overlay content", modifier = Modifier.align(Alignment.Center))
    }
}

Key concepts:

  • Modifier chains layout behavior: size, padding, click handling, background. Order matters — padding then background differs from background then padding.
  • LazyColumn / LazyRow render only visible items — the replacement for RecyclerView. For a list of 1000 items, only ~15 composables exist at once.
LazyColumn {
    items(sensorReadings) { reading ->
        SensorRow(reading)
    }
}

Material 3 theming

Wrap your app in MaterialTheme and every Text, Button, and Card picks up your color scheme and typography automatically:

MaterialTheme(
    colorScheme = lightColorScheme(primary = Color(0xFF2E7D32)),
    typography = Typography()
) {
    CounterScreen()
}

Use MaterialTheme.colorScheme.primary and MaterialTheme.typography.* in your composables instead of hardcoded colors — then dark mode and branding changes propagate everywhere for free.

Navigation and project structure

For multi-screen student apps, the Navigation-Compose library maps routes to composables:

NavHost(navController = navController, startDestination = "home") {
    composable("home") { HomeScreen(onOpen = { navController.navigate("detail") }) }
    composable("detail") { DetailScreen() }
}

Keep the standard separation: composables render, ViewModels hold state, repositories fetch data. Compose doesn't replace architecture — it makes the UI layer thinner.

Common mistakes

  • Side effects inside composables. Network calls or database writes directly in a composable re-run on every recomposition — use LaunchedEffect or move them to the ViewModel.
  • Forgetting remember. State declared as a plain var resets on every recomposition, producing UI that "forgets" input as you type.
  • Heavy work during composition. Sorting a 10,000-item list inside a composable body blocks the UI thread every recomposition — hoist it with remember or move it to the ViewModel.
  • Modifier order bugs. clickable before padding vs after changes the touch target. When layout looks wrong, re-read the modifier chain order.
  • State in the wrong place. Duplicating the same state in two composables guarantees they'll disagree. Single source of truth, hoisted up.
  • Preview-less development. @Preview renders composables in Android Studio without deploying — use it for every reusable component; it catches layout bugs in seconds.

Where to go from here

More project guides

More in Android