Core Of JetPack Compose: What is Stateless, Stateful, Composition, Recomposition, and State Hoisting? (Explained With Practical Example)

Animesh Roy
8 min readAug 10, 2024

--

In this article first, we will understand in a simple way what all those core concepts mean in the context of JetPack Compose, then I will go through a simple Android App to show you how all those concepts fit in.

Every below concept is connected to one another at its core and if you don’t understand at first, wait for the practical App example, it is all going to make sense.

Table of contents covered:

  1. What is State in JetPack Compose?
  2. Events in Compose.
  3. Understand Stateful Vs. Stateless Composable.
  4. Understand Composition and Recomposition.
  5. What is State Hoisting? and how to implement it…
  6. Create a simple TipTime App and apply all the above concepts.

What is State in JetPack Compose

An App’s state is any value that changes over time, State determines what to show in the UI at any particular time. For example:

  • Select and deselect a radio button.
  • Changes values in TextField.
  • The scroll position of a list item.
  • In the TipTime App, the state is the bill amount.

There are two types of State: Static and Dynamic(the value that changes over time), When we say state we are basically emphasizing Dynamic state. Because static state is just a hardcoded value(count variable) that doesn’t change like in the below example. We can say count variable is a static state.

Static State:

@Composable
fun NumberCounter(modifier: Modifier = Modifier) {
val count = 0
Text(
text = "You have $count count.",
modifier = modifier.padding(16.dp)
)
}

Dynamic State:

To reflect the state changes in the UI, we need to make our state observable or tracked by Compose using the State and MutableState types. The State type is immutable, which means you can only read the value in it while the MutableState is mutable, where you can read and write the value in it. We often use mutableStateOf() function to create observable MutableState.

Don’t get confused between the state as a concept and state(immutable) as a type.

The state is then updated in response to the Event like a Button click and recomposition(we will talk about this later) takes place, everything works well at this point but compose cannot preserve/save the state during the recomposition function. To make it preserve the existing state what we need to do is to wrap the observable with remember, A value calculated by remember is stored in the composition and the stored value is kept across recomposition. Here is an example:

@Composable
fun NumberCounter(modifier: Modifier = Modifier) {

Column(modifier = modifier.padding(16.dp)) {

val count: MutableState<Int> = remember { mutableStateOf(0) }

Text(text = "You have ${count.value} count.", modifier = modifier.padding(16.dp))

Button(onClick = { count.value++ },
modifier = Modifier.padding(top = 8.dp)){
Text("Keep adding...")
}
}
}

Events in Compose

In the last point, we talked about the state which is any value that changes over time, but what causes the state to update? In Android Apps, the state is updated in response to events.

Events are input generated from outside or inside an application, such as:

  • The user interacts with the UI, such as pressing a button or image or typing a value in TextField.
  • API sending Network Responses.

While the state of the app offers a description of what to display in the UI, events are the mechanism through which the state changes, resulting in changes to the UI.

Understand Stateless Vs. Stateful Composable

Stateless

In Jetpack Compose, A stateless composable is a composable ​​that doesn’t store its own state. It displays whatever state it’s given as input arguments. Understanding the difference between stateful and stateless composables is crucial for efficient UI design. Stateless composables are purely functional, they receive data as parameters and rely on external state management for their behavior. They don’t hold or manage any state themselves and simply render UI based on the inputs they receive. This makes them predictable and easy to test.

In the code snippet below, you can clearly see that there is no amountInput observable state(See the Stateful example below after this)

@Composable
fun EditNumberField(
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier
) {
TextField(
value = value,
singleLine = true,
modifier = modifier,
onValueChange = onValueChanged,
label = { Text(stringResource(R.string.bill_amount)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}

Stateful

Stateful composable owns a piece of share that can change over time. Stateful composables manage their own state using remember and mutableStateOf. They can directly handle changes and trigger recompositions when their internal state changes. This means they are responsible for both the state and the UI rendering, making them more self-contained but potentially more complex to manage.

Balancing stateful and stateless composables in your application helps in creating modular, maintainable, and performant UIs, as stateless composables can be reused easily while stateful composables handle dynamic data and interactions.

In the code snippet below, the function EditNumberField is Stateful because it has the amountInput state that is being called in the Event handler OnValueChange.

@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
var amountInput by remember { mutableStateOf("") }
TextField(
value = amountInput,
onValueChange = { amountInput = it },
singleLine = true,
label = { Text(stringResource(R.string.bill_amount)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier
)
}

What is State Hoisting? and how to implement it…

When we extract state from a composable function, which is stateful before and after extracting, the composable function is now stateless. That is, composable functions can be made stateless by extracting state from them. This pattern is called state hoisting. In the next, we will see how we hoist, or lift, the state from a composable to make it stateless.

State hoisting is a pattern of moving state to its caller to make a component stateless.

Let’s see an example, In the example we will first create a stateful function, and then we will make it stateless after making it stateless, we will move the state to its caller function which is stateful. State in this case basically means this: var amountInput by remember { mutableStateOf(“”) }

Stateful EditNumberField:

@Composable
fun EditNumberField(modifier: Modifier = Modifier) {
var amountInput by remember { mutableStateOf("") }
TextField(
value = amountInput,
onValueChange = { amountInput = it },
singleLine = true,
label = { Text(stringResource(R.string.bill_amount)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
modifier = modifier
)
}

Stateless EditNumberField:

@Composable
fun EditNumberField(
value: String,
onValueChanged: (String) -> Unit,
modifier: Modifier
) {
TextField(
value = value,
singleLine = true,
modifier = modifier,
onValueChange = onValueChanged,
label = { Text(stringResource(R.string.bill_amount)) },
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}

Moving the state to its caller function TipTimeLayout():

@Composable
fun TipTimeLayout() {
var amountInput by remember { mutableStateOf(“”) } // here is the state

val amount = amountInput.toDoubleOrNull() ?: 0.0
val tip = calculateTip(amount)

Column(
modifier = Modifier
.statusBarsPadding()
.padding(horizontal = 40.dp)
.verticalScroll(rememberScrollState())
.safeDrawingPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {

Text(
text = stringResource(R.string.calculate_tip),
modifier = Modifier
.padding(bottom = 16.dp, top = 40.dp)
.align(alignment = Alignment.Start)
)

EditNumberField( // TipTimeLayout is caller function of EditNumberField
value = amountInput,

onValueChanged = { amountInput = it }, // state called in Event handler.

modifier = Modifier.padding(bottom = 32.dp).fillMaxWidth()
)

Text(
text = stringResource(R.string.tip_amount, tip),
style = MaterialTheme.typography.displaySmall
)
Spacer(modifier = Modifier.height(150.dp))
}
}

Hope you understand what State Hoisting means :) it means hoisting the state to its caller. Caller here is TipTimeLayout, As simple as that…

In real apps, having a 100% stateless composable can be difficult to achieve depending on the composable’s responsibilities. You should design your composables in a way that they will own as little state as possible and allow the state to be hoisted when it makes sense, by exposing it in the composable’s API.

Understand Composition and Recomposition

The Composition is a description of the UI built by Compose when it executes composables. Compose apps call composable functions to transform data into UI. If a state change happens, Compose re-executes the affected composable functions with the new state, which creates an updated UI — this is called recomposition. Compose schedules a recomposition for you.

Composition

When Compose runs your composables for the first time during initial composition, it keeps track of the composables that you call to describe your UI in a Composition.

Recomposition

Recomposition is when Compose re-executes the composables that may have changed in response to data changes and then updates the Composition to reflect any changes.

The Composition can only be produced by an initial composition and updated by recomposition. The only way to modify the Composition is through recomposition. To do this, Compose needs to know what state to track so that it can schedule the recomposition when it receives an update. In your case, it’s the amountInput variable, so whenever its value changes, Compose schedules a recomposition.

Learn about this more in the official Google docs.

Create a simple TipTime App and apply all the above concepts

This app takes a user input in TextField and calculates Tip. I will explain the topic covered above in the code comment itself.

TipTimeApp

Full Code MainActivity.kt:

package com.appmakerszone.tiptime

import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawingPadding
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
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
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.appmakerszone.tiptime.ui.theme.TipTimeTheme
import java.text.NumberFormat

class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
setContent {
TipTimeTheme {
TipTimeLayout()
}
}
}
}

/*
- TipTimeLayout is a stateful composable function becuase it has now the observeable
variable that is amountInput.

- TipTimeLayout is the caller function becuase it calls the EditNumberField
stateless composable.
*/
@Composable
fun TipTimeLayout() {

/*
- Moved the state amountInput from EditNumberField. So State Hoisting occured.
- mutableStateOf wrapped in remember becuase to preserve state during recomposition.
- making our state change/tracked or observed using the MutableState with initla value none.
*/

var amountInput by remember { mutableStateOf(“”) } // observeable state
val amount = amountInput.toDoubleOrNull() ?: 0.0
val tip = calculateTip(amount = amount)

Column(
modifier = Modifier
.fillMaxSize()
.statusBarsPadding()
.padding(horizontal = 40.dp)
.verticalScroll(rememberScrollState())
.safeDrawingPadding(),
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Center
) {
Text(
text = stringResource(R.string.calculate_tip),
modifier = Modifier
.padding(start = 10.dp, bottom = 16.dp, top = 40.dp)
.align(alignment = Alignment.Start)
)

EditNumberField(
value = amountInput,
onValueChange = { amountInput = it }, // event handler, when we make change or type a new value in the TextField it gets triggered and recomposition occurs.
modifier = Modifier
.padding(bottom = 35.dp)
.fillMaxWidth()
)
Text(
text = stringResource(R.string.tip_amount, tip),
style = MaterialTheme.typography.displaySmall,
fontSize = 25.sp
)
Spacer(modifier = Modifier.height(150.dp))
}
}

// It's now Stateless
@Composable
fun EditNumberField(
value: String,
onValueChange: (String) -> Unit, // Lambda function that take a string as argument.
modifier: Modifier = Modifier
) {

TextField(
value = value,
onValueChange = onValueChange, // Event handler callback
label = {
Text(text = stringResource(id = R.string.bill_amount))
},
modifier = modifier,
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number)
)
}

private fun calculateTip(amount: Double, tipPercent: Double = 15.0): String {
val tip = tipPercent / 100 * amount
return NumberFormat.getCurrencyInstance().format(tip)
}

@Preview(showBackground = true, showSystemUi = true)
@Composable
fun GreetingPreview() {
TipTimeTheme {
TipTimeLayout()
}
}

That’s it… Hope you learned something from this article. Happy Coding!

Thanks for reading, if you find it helpful. Please do consider a clap and a follow for more interesting article like this!

--

--