Mastering State Hoisting for Cleaner and More Testable Code in Jetpack Compose

Veronica Putri Anggraini
Bootcampers
Published in
6 min readApr 19, 2023

Halo folks, in this article let’s discuss about state hoisting. On the previous article, I already share about the simplest way to manage state using Mutable State. When you haven’t read that, better for you to check this link, to get clear understanding about state management.

State hoisting is one of important concept on Jetpack Compose. But before jumping into it, we need to know about composable function first. Basically there are two composable function Stateful and Stateless.

  1. Stateful, composable function that stored state. We will find remember function inside composable function to declare the variable that will stored the state value. Through Stateful Composable make the composable parent no need to control the child composable, because the child composable can managed the composable by itself. But it will make this function not reusable and of course is not testable, because we can’t control the logic inside.
  2. Stateless, composable function that haven’t stored the state. It’s possible for us to control the logic from outside and easier to test.

State closely tied with Jetpack Compose, so the use of stateful composable is unavoidable. That’s why we need to make the stateful composable function become stateless. To realize that, we can use State Hoisting pattern to move the state to parent on it, so the composable became stateless.

Let’s try to the simple case, I have the following code below :

  @Composable
fun FavoriteButton(modifier: Modifier = Modifier) {
var favorite by remember { mutableStateOf(false) }
var color by remember { mutableStateOf(Color.Gray) }

color = if (favorite) {
Color.Red
} else {
Color.Gray
}
Image(Icons.Default.Favorite,
contentDescription = null,
colorFilter = ColorFilter.tint(color),
modifier = modifier
.size(200.dp)
.clickable {
favorite = true
if (color == Color.Red) {
favorite = false
}
}
)
}

Now, move the state to the parent top FavoriteAppScreen()

Move the color and favorite state to parent top or caller’s function. We can create several parameter on the bottom function. Now the FavoriteButton become stateless.

  • favorite: Boolean, parameter for hold the current value of favorite state.
  • onFavoriteValueChanged: (Boolean) -> Unit, parameter for hold the changed value of favorite state.
  • color: Color, parameter for hold the color.

After the state added on FavoriteAppScreen, we can call the stateless function and assign the state as a parameter value. So we can conclude that the state that already move can be lowered to the bottom function.

Actually, when the composable function that have a state and the logic is simple. We don’t need to move the state to the parent top. Because, basically internal state can be left as a composable or lift it to the top according to the need.

Now, let’s talk about the advantages when using state hoisting pattern:

  1. Single source of truth, this is will help avoid bugs because there is only one source of truth by moving the state instead of duplicating it.
  2. Encapsulated, only stateful composable that will be able to modify their state, so it’s completely internal.
  3. Shareable, hoisted state can be shared with multiple composable.
  4. Interceptable, composable function callers to stateless composable can decide to ignore or modify events before changing the state.

To get better understanding about state hoisting implementation, let’s create project step by step:

  • Create the predicate status as enum class PredicateStatus.kt, like the code below:
enum class PredicateStatus(val predicate: String) {
OUTSTANDING("Oustanding (O)"),
EXCEEDS_EXPECTATIONS("Exceeds Expectations (E)"),
ACCEPTABLE("Acceptable (A)"),
POOR("Poor (P)"),
DREADFUL("Dreadful (D)"),
TROLL("Troll (T)")
}
  • Next create Utils.kt and add adding function getPredicateStatus with scorevalue parameter with Double data type. For more clear you can follow the code below:
package com.veroanggra.migratingsample

fun getPredicateStatus(scoreValue: Double): PredicateStatus {
return when (scoreValue) {
in 0.0..50.0-> PredicateStatus.TROLL
in 50.1..60.0 -> PredicateStatus.DREADFUL
in 60.1..70.0 -> PredicateStatus.POOR
in 70.1..80.0 -> PredicateStatus.ACCEPTABLE
in 80.1..90.0 -> PredicateStatus.EXCEEDS_EXPECTATIONS
else -> PredicateStatus.OUTSTANDING
}
}
  • Then, make custom widget to input text in the MainActivity.kt, like the code below
@Composable
fun InputTextField(
modifier: Modifier = Modifier,
action: ImeAction,
label: String,
value: String,
onValueChanged: (String) -> Unit,
keyboardType: KeyboardType,
keyboardActions: KeyboardActions
) {
OutlinedTextField(
value = value,
onValueChange = onValueChanged,
label = { Text(text = label) },
colors = TextFieldDefaults.outlinedTextFieldColors(
focusedBorderColor = YellowD8AC4E,
unfocusedBorderColor = Color.Gray,
focusedLabelColor = YellowD8AC4E,
unfocusedLabelColor = Color.Gray
),
keyboardActions = keyboardActions,
keyboardOptions = KeyboardOptions(
keyboardType = keyboardType,
imeAction = action
), modifier = modifier
.padding(start = 20.dp, end = 20.dp, top = 5.dp)
.fillMaxWidth()
.height(80.dp)
)
}
  • The next step is call the InputTextField widget on composable function that used for form. Like the code below:
@Composable
fun ScoreForm(
modifier: Modifier,
name: String,
onNameValueChange: (String) -> Unit,
score: String,
onScoreValueChange: (String) -> Unit
) {
val focusManager = LocalFocusManager.current
Column(
modifier = modifier
.wrapContentHeight()
.fillMaxWidth()
) {
InputTextField(
action = ImeAction.Next,
label = "Type Your Name",
value = name,
onValueChanged = onNameValueChange,
keyboardType = KeyboardType.Text,
keyboardActions = KeyboardActions(onNext = { focusManager.moveFocus(androidx.compose.ui.focus.FocusDirection.Down) })
)
InputTextField(
action = ImeAction.Done,
label = "Type Your Score",
value = score,
onValueChanged = onScoreValueChange,
keyboardType = KeyboardType.Number,
keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() })
)
}
}
  • Create composable function for PredicateScreen and add several variable as state, like the code below
var name by remember {
mutableStateOf("")
}

var score by remember {
mutableStateOf("")
}

val validName = remember(name) {
name.isNotEmpty()
}

val validScore = remember(score) {
score.isNotEmpty()
}

var showPredicate by remember {
mutableStateOf(false)
}

var showFinaleScore by remember {
mutableStateOf(0.0)
}

name, hold value of string name from user input.
score, hold value of string score from user input.
validName, hold value of name is not empty.
validScore, hold value of boolean status, to show prediacte or not.
showFinalScore, hold value of score that already convert to double.

  • And for the full code of PredicateScreen will be like below:
@Composable
fun PredicateScreen(modifier: Modifier = Modifier) {
var name by remember {
mutableStateOf("")
}

var score by remember {
mutableStateOf("")
}

val validName = remember(name) {
name.isNotEmpty()
}

val validScore = remember(score) {
score.isNotEmpty()
}

var showPredicate by remember {
mutableStateOf(false)
}

var showFinaleScore by remember {
mutableStateOf(0.0)
}

ConstraintLayout(modifier = modifier.fillMaxSize()) {
val (title, form, buttonRow, resultText, firstSpacer) = createRefs()
Text(
text = "Write Your Score and Get \nYour Predicate!",
fontSize = 30.sp, fontWeight = FontWeight.Bold, lineHeight = 35.sp,
modifier = modifier
.padding(start = 20.dp, top = 20.dp)
.constrainAs(title) {
start.linkTo(parent.start)
top.linkTo(parent.top)
})
ScoreForm(
name = name,
onNameValueChange = { name = it },
score = score,
onScoreValueChange = { score = it },
modifier = modifier
.padding(top = 20.dp)
.constrainAs(form) {
top.linkTo(title.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
})
Row(
modifier = modifier
.constrainAs(buttonRow) {
top.linkTo(form.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
}
.fillMaxWidth()
.padding(12.dp),
horizontalArrangement = Arrangement.SpaceEvenly
) {
Button(
onClick = {
if (!validName || !validScore) return@Button
showPredicate = true
showFinaleScore = score.toDouble()
},
modifier = modifier
.width(150.dp)
.height(50.dp),
colors = ButtonDefaults.buttonColors(
Pink80
)
) {
Text(text = "Check")
}

Button(
onClick = {
name = ""
score = ""
showPredicate = false
},
modifier = modifier
.width(150.dp)
.height(50.dp),
colors = ButtonDefaults.buttonColors(
Pink80
)
) {
Text(text = "Reset")
}
}
Spacer(modifier = modifier
.height(10.dp)
.constrainAs(firstSpacer) {
top.linkTo(buttonRow.bottom)
start.linkTo(parent.start)
end.linkTo(parent.end)
})
if (showPredicate) {
val getPredicate = getPredicateStatus(showFinaleScore)
Text(
text = "Halo $name, \n\nYour Predicate for this semester ${getPredicate.predicate}",
fontSize = 18.sp,
modifier = modifier
.padding(start = 40.dp, end = 40.dp)
.constrainAs(resultText) {
top.linkTo(firstSpacer.bottom)
start.linkTo(parent.start)
})
}
}
}
  • Add call the PredicateScreen on setContent, like below.
setContent {
PlayingWithComposeTheme {
Surface {
PredicateScreen()
}
}
}
  • You can check the full code here, after compile it will be look like below.

The sample above move the state from ScoreForm to PredicateScreen. So it will be make the state reusable to for other functions. Well, that’s all for this article, you can check the project code on this Github Repositories inside branch score-predicate-app, hopefully useful and see you in the next article!!! Don’t forget to follow and👏🏻 for this article because it means a lot to me to be more enthusiastic about writing the next content — :).

--

--

Veronica Putri Anggraini
Bootcampers

Software Engineer Android @LINE Bank 🤖 Google Developer Expert for Android https://github.com/veroanggra