Managing State in Jetpack Compose

Learn the differences between stateless and stateful composables and how state hoisting can help make your composables more reusable. By Rodrigo Guerrero.

5 (2) · 1 Review

Download materials
Save for later
Share
You are currently viewing page 3 of 4 of this article. Click here to view the first page.

Implementing State Hoisting

Open RegisterUserComposables.kt. Start implementing state hoisting on EditTextField(). This composable needs two variables for its state: one that holds the text that the user is typing and another that holds the state to show whether there is an error. Also, it needs a callback to notify the state holder that the text value changed.

Add the following lines at the top of the parameters list in EditTextField():

// 1.
value: String,
// 2.
isError: Boolean,
// 3.
onValueChanged: (String) -> Unit,

Here is an explanation for this code:

  1. value will receive the current text value for the EditTextField.
  2. isError indicates whether the current text value is valid or invalid so the EditTextField displays an error indicator, if needed.
  3. onValueChanged will execute whenever the value changes.

Next, remove the following code:

val text = remember {
  mutableStateOf("")
}

Because you are now receiving the state in these parameters, the composable doesn’t need to remember its state.

Now, update OutlinedTextField() as follows:

OutlinedTextField(
  // 1.
  value = value,
  // 2.
  isError = isError,
  // 3.
  onValueChange = { onValueChanged(it) },
  leadingIcon = { Icon(leadingIcon, contentDescription = "") },
  modifier = modifier.fillMaxWidth(),
  placeholder = { Text(stringResource(placeholder)) }
)

In this code, you:

  1. Use value to set the current value of the OutlinedTextField().
  2. Set the value of isError using the parameter.
  3. Execute onValueChanged() when the text parameter changes. Now you don’t need to update the remember() value — you only need to hoist this value up.

Amazing! EditTextField is now stateless. Because you are implementing state hoisting, now RegistrationFormScreen() needs to receive the state for the EditTextFields.

Add the following parameters to RegistrationFormScreen():

// 1.
registrationFormData: RegistrationFormData,
// 2.
onEmailChanged: (String) -> Unit,
// 3.
onUsernameChanged: (String) -> Unit,

With this code, you added:

  1. A registrationFormData value that contains all the data needed for the registration form.
  2. onEmailChanged() that will execute when the user updates the email text field.
  3. onUsernameChanged() that will execute when the user updates the username text field.

Finally, you need to pass these values to each EditTextField. Update both EditTextFields as follows:

EditTextField(
  leadingIcon = Icons.Default.Email, 
  placeholder = R.string.email,
  // 1.
  value = registrationFormData.email,
  // 2.
  isError = !registrationFormData.isValidEmail,
  // 3.
  onValueChanged = { onEmailChanged(it) }
)

EditTextField(
  leadingIcon = Icons.Default.AccountBox,
  placeholder = R.string.username,
  modifier = Modifier.padding(top = 16.dp),
  // 4.
  value = registrationFormData.username,
  // 5.
  isError = false,
  // 6.
  onValueChanged = { onUsernameChanged(it) }
)

With this code, you:

  1. Use registrationFormData.email to set the email value.
  2. Use registrationFormData.isValidEmail to show whether there is an error in the email field.
  3. Execute onEmailChanged() whenever the email value changes.
  4. Use registrationFormData.username to set the username value.
  5. Set isError to false since this field doesn’t have validation.
  6. Execute onUsernameChanged() whenever the username value changes.

Open MainActivity.kt and add the following line below the formViewModel declaration:

val registrationFormData by formViewModel.formData.observeAsState(RegistrationFormData())
Note: Ensure you have import androidx.compose.runtime.getValue at the top of the MainActivity.

formViewModel contains a LiveData variable called formData that contains the state for the registration screen.

In this line, you observe this variable as state, using observeAsState(). You need to set its default value. You can use the default values with RegistrationFormData(), which make the form have empty text fields and a preselected radio button.

Whenever a value in formData changes, FormViewModel has logic to update registrationFormData‘s state. This new value propagates down to the composables that use it, triggering the recomposition process.

Finally, update the call to RegistrationFormScreen() like this:

RegistrationFormScreen(
  // 1. 
  registrationFormData = registrationFormData,
  // 2.
  onUsernameChanged = formViewModel::onUsernameChanged,
  // 3.
  onEmailChanged = formViewModel::onEmailChanged,
)

In this code, you:

  1. Pass the registrationFormData state to the registration form screen.
  2. Call onUsernameChanged() within the ViewModel when the username changes. This function updates the registrationFormData content with the new username value.
  3. Call onEmailChanged() within the ViewModel when the email changes. This function updates the registrationFormData content with the new email value.

Build and run. Open the registration screen. You can now add an email and username and see the value you type on the screen. Also, you can now check whether the email you entered is valid. Hooray!

Stateless EditTexts

Next, you’ll implement state hoisting to make the radio buttons and drop-down work.

Implementing State Hoisting in Other Composables

It’s time to make the radio button and drop-down composables stateless. Open RegisterUserComposables.kt and add the following parameters to RadioButtonWithText, above the text parameter:

isSelected: Boolean,
onClick: () -> Unit,

Here you pass the radio button isSelected parameter along with its onClick() callback.

Now, remove the following code:

val isSelected = remember {
  mutableStateOf(false)
}

Because RadioButtonWithText now receives its state, this remember() is no longer needed.

Next, update the RadioButton composable like this:

RadioButton(
  selected = isSelected,
  onClick = { onClick() }
)

Here you assign both the value and the callback. With this, you made RadioButtonWithText() stateless.

The drop-down menu works differently than the previous composables. In this case, the drop-down menu needs a state that indicates whether it’s in its expanded state. You don’t need to hoist this state because it’s only needed by the drop-down composable itself.

On the other hand, this component needs state that has the selected item. In this case, you need a hybrid composable: part of its state is hoisted while the component still has some intrinsic state.

Update DropDown() adding these parameters above the menuItems parameter:

selectedItem: String,
onItemSelected: (String) -> Unit,

selectedItem will hold the selected value and onItemSelected() is the callback that executes when the user selects an item.

Next, remove the following code:

val selectedItem = remember {
  mutableStateOf("Select your favorite Avenger:")
}

Because the composable receives selectedItem, it doesn’t need this remember() anymore.

Next, update Text(selectedItem.value), like this:

Text(selectedItem)

With this code, Text() uses the selectedItem parameter to display its value.

Finally, update DropDownMenuItem() as follows:

DropdownMenuItem(onClick = {
  onItemSelected(menuItems[index])
  isExpanded.value = false
}) {
  Text(text = name)
}

Here, you call onItemSelected() when the user selects a value. With this code, DropDown() is now a hybrid composable.

Now, update the composables caller. Add the following parameters to RegistrationFormScreen(), below the line onUsernameChanged: (String) -> Unit,:

onStarWarsSelectedChanged: (Boolean) -> Unit,
onFavoriteAvengerChanged: (String) -> Unit,

Here, you updated the parameters to receive the different callbacks the radio buttons and drop-down menu need.

Update both radio buttons code within RegistrationFormScreen() like this:

RadioButtonWithText(
  text = R.string.star_wars,
  isSelected = registrationFormData.isStarWarsSelected,
  onClick = { onStarWarsSelectedChanged(true) }
)

RadioButtonWithText(
  text = R.string.star_trek,
  isSelected = !registrationFormData.isStarWarsSelected,
  onClick = { onStarWarsSelectedChanged(false) }
)

In the code above, you use the state parameters and assign the callbacks to both radio buttons.

Now, update the drop-down like this:

DropDown(
  menuItems = avengersList,
  // 1.
  onItemSelected = { onFavoriteAvengerChanged(it) },
  // 2.
  selectedItem = registrationFormData.favoriteAvenger
)

Here, you:

  1. Execute the onFavoriteAvengerChanged() callback when selecting an item.
  2. Set the selected value to display your favorite Avenger.

Open MainActivity.kt and update RegistrationFormScreen() call like this:

RegistrationFormScreen(
  registrationFormData = registrationFormData,
  onUsernameChanged = formViewModel::onUsernameChanged,
  onEmailChanged = formViewModel::onEmailChanged,
  onStarWarsSelectedChanged = formViewModel::onStarWarsSelectedChanged,
  onFavoriteAvengerChanged = formViewModel::onFavoriteAvengerChanged,
)

In this code, you pass the radio buttons and drop-down state variables and assign the event callbacks to the registration screen.

Build and run. Open the registration screen. Now, you can make a selection with the radio buttons and select your favorite Avenger, like in this image:

Stateless RadioButtons and Dropdown

Finally, to finish this up, you’ll make the buttons work.