Skip to content
Blog

Android Jetpack Compose Patterns

Idiomatic Jetpack Compose patterns in 2026 — state hoisting, unidirectional data flow, composable structure, and integrating ViewModel with the modern lifecycle.

Published on August 14, 2026

AI Assistant

Android Jetpack Compose Patterns

You’ve written Compose that works, but the whole screen recomposes when you type one character in a text field, the ViewModel is passed down through five layers of composables, and your “detail” navigation requires a JSON string that both sides of the route must spell perfectly. If this sounds familiar, you’re not writing bad Compose — you’re missing the idioms.

In this tutorial, you will learn the canonical Jetpack Compose patterns that Android’s architecture guidance recommends in 2026: state hoisting, unidirectional data flow, stateless vs stateful composables, slots and modifiers, type-safe navigation, and a complete loading/error/success screen driven by a ViewModel. Every pattern is paired with runnable Kotlin and explained line by line.

Key technologies: Jetpack Compose, Kotlin, StateFlow, collectAsStateWithLifecycle, androidx.lifecycle ViewModel, Navigation Compose.

Prerequisites

  • Android Studio with Compose support (current stable in 2026)
  • Working knowledge of Kotlin (coroutines, sealed classes, lambda syntax)
  • Familiarity with Gradle and build.gradle.kts dependency management
  • Basic understanding of Flow (the kotlinx.coroutines.flow package)

How Compose Thinks: Recomposition

A composable function is a pure renderer: it takes inputs (state and lambdas) and produces UI. When the state a composable reads changes, Compose recomposes — it re-runs that composable to figure out what changed. Your job is to give Compose as little to recompose as possible, and to make every state change obvious.

remember and rememberSaveable

Local, ephemeral state belongs in remember. It’s a cache that lives as long as the composable stays in the composition:

@Composable
fun SearchBox() {
    var query by remember { mutableStateOf("") }

    TextField(
        value = query,
        onValueChange = { query = it },
        placeholder = { Text("Search") }
    )
}

remember survives recomposition but not configuration changes (rotation) or process death. When the value must survive rotation, use rememberSaveable, which writes the value into the saved instance state bundle:

var selectedTab by rememberSaveable { mutableStateOf(0) }

Both work because they return a MutableState<T> — the property-delegate by gives you the wrapped value, and Compose tracks reads so it knows exactly where to recompose.

The recomposition golden rule

remember caches the result of expensive work so it runs once instead of on every frame of an animation. Compose can recompose a function dozens of times per second, so keep the body cheap, wrap expensive computations in remember, and — critically — never write to state during composition (only in response to events). Writing state in the middle of a body that already read it creates an infinite recomposition loop.

State Hoisting: State vs Events

State hoisting is the pattern of moving state up to the composable’s caller, making the child stateless. The general recipe from the official guidance is to replace the state variable with two parameters:

  • value: T — the current value to display
  • onValueChange: (T) -> Unit — an event that requests the change

Here’s the same text field hoisted:

@Composable
fun SearchField(
    value: String,
    onValueChange: (String) -> Unit
) {
    TextField(
        value = value,
        onValueChange = onValueChange,
        placeholder = { Text("Search") }
    )
}

// Caller owns the state
@Composable
fun SearchScreen() {
    var query by remember { mutableStateOf("") }
    SearchField(value = query, onValueChange = { query = it })
}

Why does this matter? Hoisted state has a single source of truth — there’s only one copy of query, owned by SearchScreen, so no duplication bugs. The child is now reusable and testable: you can render SearchField with any value and any callback. And it’s trivially swappable — when business logic needs query, you hoist it higher (into a state holder or ViewModel) without touching SearchField at all.

The official best practice: hoist state to the lowest common ancestor of every composable that reads or writes it, keeping state as close to its consumers as possible. When business logic is involved, that ancestor moves outside the Composition entirely — into a ViewModel.

Unidirectional Data Flow with ViewModel

For screen-level state, Compose’s recommended pattern is unidirectional data flow: the ViewModel produces immutable UI state as a StateFlow, the UI collects it, and events flow down from the UI to the ViewModel through function calls. State never flows up.

The ViewModel

import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.stateIn

class MessagesViewModel(
    private val messagesRepository: MessagesRepository
) : ViewModel() {

    val messages: StateFlow<List<Message>> =
        messagesRepository.getLatestMessages()
            .stateIn(
                scope = viewModelScope,
                started = SharingStarted.WhileSubscribed(5_000),
                initialValue = emptyList()
            )

    fun sendMessage(message: Message) {
        messagesRepository.send(message)
    }
}

stateIn converts a cold Flow into a hot StateFlow. WhileSubscribed(5_000) keeps the upstream flow active only while someone is collecting — and for 5 seconds after — so work stops when the screen leaves composition. A StateFlow always holds the latest value, which is exactly what a recomposing UI wants.

Collecting state in the UI

The recommended way to collect a Flow in a composable is collectAsStateWithLifecycle(). It’s lifecycle-aware: it only collects while the lifecycle is at least STARTED, saving resources when the app is backgrounded. It’s part of androidx.lifecycle:lifecycle-runtime-compose:

dependencies {
    implementation("androidx.lifecycle:lifecycle-runtime-compose:2.10.0")
    implementation("androidx.lifecycle:lifecycle-viewmodel-compose:2.10.0")
}
@Composable
fun ConversationRoute(viewModel: MessagesViewModel = viewModel()) {
    val messages by viewModel.messages.collectAsStateWithLifecycle()

    ConversationScreen(
        messages = messages,
        onSendMessage = viewModel::sendMessage
    )
}

Notice the split: ConversationRoute is the screen-level composable that owns the ViewModel and collects state; ConversationScreen is a stateless composable that only renders. Never pass the ViewModel itself down into child composables — pass plain values and event lambdas. This keeps every descendant dumb, previewable, and trivially testable.

Stateless vs Stateful Composables

This two-layer split is a pattern you should apply everywhere:

  • Stateful composables own state (remember, rememberSaveable, or a ViewModel via viewModel()). They know where state comes from.
  • Stateless composables receive everything as parameters and emit everything as callbacks. They know how to render.

The general rule: make composables stateless when you can, stateful when you must. A stateless composable is reusable in any context, has a stable preview, and is far easier to unit test. The stateful version is just a thin wrapper that owns the state and delegates to the stateless one — giving callers the choice of which to use.

Slots and Modifiers

Two small patterns make composables much more composable.

Slots: instead of hardcoding content, accept a @Composable lambda. This is how Material’s Scaffold works — you pass topBar =, floatingActionButton =, and content =:

@Composable
fun Card(title: String, actions: @Composable () -> Unit = {}) {
    Column {
        Text(title)
        actions()
    }
}

Card(title = "Sessions") {
    Button(onClick = { /* play */ }) { Text("Play") }
}

Modifiers: the first parameter of a public composable should be modifier: Modifier = Modifier, passed to the outermost layout so callers can control size, padding, and click behavior from outside. Never swallow a modifier — thread it through to the root.

@Composable
fun SessionRow(session: Session, modifier: Modifier = Modifier) {
    Row(
        modifier = modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp, vertical = 8.dp)
    ) {
        Text(session.title, modifier = Modifier.weight(1f))
    }
}

For multi-screen apps, Navigation Compose (androidx.navigation:navigation-compose) replaces manual screen switching. A NavController (created with rememberNavController()) is bound to a single NavHost, which declares the graph via the Kotlin DSL.

Modern Navigation (2.8+) supports type-safe routes using @Serializable data objects/classes instead of fragile strings:

import androidx.navigation.compose.NavHost
import androidx.navigation.compose.composable
import androidx.navigation.compose.rememberNavController
import androidx.navigation.toRoute
import kotlinx.serialization.Serializable

@Serializable
object HomeRoute

@Serializable
data class DetailRoute(val sessionId: String)

@Composable
fun AppNavHost() {
    val navController = rememberNavController()

    NavHost(
        navController = navController,
        startDestination = HomeRoute
    ) {
        composable<HomeRoute> {
            HomeScreen(onSessionClick = { id ->
                navController.navigate(DetailRoute(id))
            })
        }
        composable<DetailRoute> { backStackEntry ->
            val route: DetailRoute = backStackEntry.toRoute()
            DetailScreen(sessionId = route.sessionId)
        }
    }
}

Routes are now typed objects — a typo is a compile error, not a runtime crash, and refactoring a route is as easy as renaming a class. Pass only lightweight IDs in routes; let the destination’s ViewModel load the heavy data.

A Worked Screen: Loading, Error, and Success

Let’s tie everything together with the canonical screen state pattern: a sealed interface for UI state, a ViewModel that produces it, and a screen that renders all three cases.

sealed interface SessionsUiState {
    data object Loading : SessionsUiState
    data class Success(val sessions: List<Session>) : SessionsUiState
    data class Error(val message: String) : SessionsUiState
}

class SessionsViewModel(
    private val repository: SessionsRepository
) : ViewModel() {

    private val _uiState = MutableStateFlow<SessionsUiState>(SessionsUiState.Loading)
    val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()

    init {
        load()
    }

    fun retry() = load()

    private fun load() {
        _uiState.value = SessionsUiState.Loading
        viewModelScope.launch {
            _uiState.value = try {
                SessionsUiState.Success(repository.getSessions())
            } catch (e: Exception) {
                SessionsUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

The sealed interface encodes the entire screen state in the type system — it’s impossible to show an error while loading because Loading, Success, and Error are mutually exclusive. The UI then becomes a single exhaustive when:

@Composable
fun SessionsScreen(viewModel: SessionsViewModel = viewModel()) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    when (val state = uiState) {
        SessionsUiState.Loading -> CircularProgressIndicator()

        is SessionsUiState.Error -> Column {
            Text("Something went wrong: ${state.message}")
            Button(onClick = viewModel::retry) { Text("Retry") }
        }

        is SessionsUiState.Success -> LazyColumn {
            items(
                items = state.sessions,
                key = { it.id }           // stable keys prevent needless recomposition
            ) { session ->
                SessionRow(session)
            }
        }
    }
}

The key = { it.id } parameter is a performance pattern: without stable keys, inserting an item at the top of a LazyColumn makes Compose think every row changed and recompose them all. With keys, Compose matches rows to their prior state and skips anything unchanged.

Performance: Avoiding Recomposition Pitfalls

Recomposition is cheap when it’s small; the goal is to keep it small. The official best-practices checklist:

  1. Provide stable keys to lazy layoutsitems(items, key = { it.id }), as above.
  2. Wrap expensive calculations in remember — or remember(key) { ... } to recompute only when the key changes.
  3. Use derivedStateOf for rapidly changing inputs — when a value changes more often than the state derived from it (for example deriving a “scroll to top” button from LazyListState), derivedStateOf computes lazily and skips recomposition when the derived value is unchanged.
  4. Defer state reads — read state as late as possible, inside lambdas or in the smallest composable, so a change recomposes only that subtree.
  5. Avoid backwards writes — never write to state that a composable has already read in the same composition; do it in event handlers (onClick, etc.).
  6. Keep lazy item content lightweight — each row should be cheap to compose; heavy work belongs in remember inside the item.

The Android team’s own hero benchmarks show Compose 1.9 and later match Views performance for jank while scrolling — the gap is closed. What separates a smooth Compose app from a janky one today is almost always these idioms, not the framework.

Putting It All Together

Here is the full pattern stack assembled into one screen: a type-safe navigation host, a screen-level composable collecting a ViewModel’s StateFlow with collectAsStateWithLifecycle, a sealed UiState handled by an exhaustive when, a stateless list with stable keys, and a hoisted search field.

// routes.kt
@Serializable
object SessionsRoute

@Serializable
data class DetailRoute(val sessionId: String)

// sessions_view_model.kt
class SessionsViewModel(repository: SessionsRepository) : ViewModel() {
    private val _uiState =
        MutableStateFlow<SessionsUiState>(SessionsUiState.Loading)
    val uiState: StateFlow<SessionsUiState> = _uiState.asStateFlow()

    init { load() }
    fun retry() = load()

    private fun load() {
        _uiState.value = SessionsUiState.Loading
        viewModelScope.launch {
            _uiState.value = try {
                SessionsUiState.Success(repository.getSessions())
            } catch (e: Exception) {
                SessionsUiState.Error(e.message ?: "Unknown error")
            }
        }
    }
}

// sessions_screen.kt
@Composable
fun SessionsScreen(
    onSessionClick: (String) -> Unit,
    viewModel: SessionsViewModel = viewModel()
) {
    val uiState by viewModel.uiState.collectAsStateWithLifecycle()

    Scaffold { innerPadding ->
        when (val state = uiState) {
            SessionsUiState.Loading ->
                Box(Modifier.fillMaxSize().padding(innerPadding),
                    contentAlignment = Alignment.Center) {
                    CircularProgressIndicator()
                }

            is SessionsUiState.Error ->
                Column(Modifier.padding(innerPadding)) {
                    Text("Something went wrong: ${state.message}")
                    Button(onClick = viewModel::retry) { Text("Retry") }
                }

            is SessionsUiState.Success ->
                LazyColumn(Modifier.padding(innerPadding)) {
                    items(state.sessions, key = { it.id }) { session ->
                        SessionRow(
                            session = session,
                            onClick = { onSessionClick(session.id) }
                        )
                    }
                }
        }
    }
}

// app_nav_host.kt
@Composable
fun AppNavHost() {
    val navController = rememberNavController()
    NavHost(navController = navController, startDestination = SessionsRoute) {
        composable<SessionsRoute> {
            SessionsScreen(onSessionClick = { id ->
                navController.navigate(DetailRoute(id))
            })
        }
        composable<DetailRoute> { backStackEntry ->
            val route: DetailRoute = backStackEntry.toRoute()
            DetailScreen(sessionId = route.sessionId)
        }
    }
}

Expected output: On launch the screen shows a centered CircularProgressIndicator. When the repository returns, the list renders with each SessionRow keyed by its stable id; tapping a row navigates to DetailRoute with a typed ID. If the repository throws, the screen switches to an error message with a Retry button that restarts the load — and because each state is a distinct branch of an exhaustive when, only the active state is ever in the composition.

Conclusion & Next Steps

The patterns are simple to state and powerful in practice: hoist state so composables stay stateless, drive screens from a ViewModel’s StateFlow collected with collectAsStateWithLifecycle, navigate with type-safe routes, and keep recomposition small with remember, derivedStateOf, and stable list keys. If you apply nothing else from this article, apply those four.

Next steps: read the official Where to hoist state guide and the State and Jetpack Compose page, then run the Using State in Jetpack Compose codelab. After that, build the loading/error/success pattern from scratch without a ViewModel first (pure remember + a LaunchedEffect), then graduate it to a ViewModel once you see why the separation pays off.

References / Sources