Part of Github Copilot with Kotlin

Jetpack Compose with Copilot: Create a button to increment a counter

Sandy LaneSandy Lane

Video: Jetpack Compose with Copilot: Create a button to increment a counter by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

Jetpack Compose: Counter Button

var count by remember { mutableStateOf(0) }. Button(onClick = { count++ }) { Text("Count: $count") }. The "Hello, World!" of Compose state management.

A small puzzle that combines episode 16 (state) and episode 14 (counts) — a button that increments a counter and displays the current value.

The Copilot prompt

// A counter button that increments a number on each click
@Composable
fun CounterButton() {

Copilot generates:

import androidx.compose.material.Button
import androidx.compose.material.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue

@Composable
fun CounterButton() {
  var count by remember { mutableStateOf(0) }

  Button(onClick = { count++ }) {
    Text("Count: $count")
  }
}

Walkthrough

1. State. var count by remember { mutableStateOf(0) } — reactive integer, starts at 0.

2. Increment in onClick. count++ increments the state. Compose detects the change and recomposes anything reading count.

3. Display in the label. "Count: $count" interpolates the current value. Each click → recompose → new label.

That's the entire pattern. Less than 10 lines of code, and it's a complete reactive counter.

mutableIntStateOf for primitives

For Int specifically, Kotlin Compose offers mutableIntStateOf:

import androidx.compose.runtime.mutableIntStateOf

var count by remember { mutableIntStateOf(0) }

mutableIntStateOf avoids boxing the int into a Java Integer — a tiny performance gain. Same applies for mutableFloatStateOf, mutableLongStateOf, etc.

For most code, mutableStateOf<Int>(0) is fine. The specialised versions matter only in hot paths.

Adding a reset button

@Composable
fun CounterApp() {
  var count by remember { mutableStateOf(0) }

  Column {
    Text("Count: $count", style = MaterialTheme.typography.headlineLarge)
    Row {
      Button(onClick = { count++ }) { Text("+1") }
      Button(onClick = { count-- }) { Text("-1") }
      Button(onClick = { count = 0 }) { Text("Reset") }
    }
  }
}

Three buttons sharing one state. Increment, decrement, reset. The state is owned by CounterApp; the buttons are dumb renderers.

Hoisting

For a reusable counter widget, hoist state up:

@Composable
fun CounterButton(count: Int, onIncrement: () -> Unit) {
  Button(onClick = onIncrement) {
    Text("Count: $count")
  }
}

@Composable
fun CounterApp() {
  var count by remember { mutableStateOf(0) }
  CounterButton(count = count, onIncrement = { count++ })
}

CounterButton is now stateless — testable, previewable, reusable. CounterApp owns the state.

With a ViewModel

For a counter that survives rotation:

class CounterViewModel : ViewModel() {
  var count by mutableStateOf(0)
    private set

  fun increment() { count++ }
  fun reset() { count = 0 }
}

@Composable
fun CounterScreen(vm: CounterViewModel = viewModel()) {
  CounterButton(count = vm.count, onIncrement = vm::increment)
}

var count by mutableStateOf(0) directly on the ViewModel — Compose can read it just like local state. The private set ensures only the ViewModel methods can mutate.

Animation on change

import androidx.compose.animation.core.animateFloatAsState

val scale by animateFloatAsState(
  targetValue = if (count % 5 == 0) 1.5f else 1f,
  animationSpec = tween(300),
  label = "scale",
)

Text(
  "Count: $count",
  modifier = Modifier.graphicsLayer(scaleX = scale, scaleY = scale),
)

Every fifth click, the count text pulses larger. Trivial polish, big perceptual payoff.

For more elaborate animations, AnimatedContent does smooth transitions when the value changes:

import androidx.compose.animation.AnimatedContent

AnimatedContent(targetState = count, label = "count") { c ->
  Text("Count: $c", style = MaterialTheme.typography.headlineLarge)
}

Slides or fades between numbers as count changes.

Saving across rotation

import androidx.compose.runtime.saveable.rememberSaveable

var count by rememberSaveable { mutableStateOf(0) }

rememberSaveable persists in the saved-instance-state bundle. Survives configuration changes (rotation, theme switch, language change).

For state that should survive process death too, use a ViewModel + DataStore.

Common mistakes

Mutating in the body. count++ outside onClick triggers an infinite recomposition loop. Always mutate in callbacks.

Forgetting by imports. getValue and setValue need to be imported for var x by ... to work.

Sharing state via top-level var. Defeats remember and breaks across screens.

Counting events vs counting state. A button that fires an event "clicked" three times can produce inconsistent counts if the click handler is async. For race-free counting, use atomic operations or hoist to a ViewModel.

Forgetting private set on a ViewModel. Without it, anyone can write to the count from outside, breaking encapsulation.

What's next

Episode 24: Filter a list to numbers divisible by 3. A filter puzzle that's similar to the divisible-by-7 count, but with the values returned.

Recap

var count by remember { mutableStateOf(0) }. Increment in onClick. Display via string interpolation. Hoist state up for reuse. For Int specifically, mutableIntStateOf avoids boxing. Survive rotation with rememberSaveable. For complex state, use a ViewModel with private set. For animated value changes, AnimatedContent and animateFloatAsState.

Next episode: filter divisible by 3.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.