One of the principles in Jetpack Compose is to
hoist state — that is, to move a composable’s state outside of the composable and push it further up, making the composable stateless. Creating stateless composables results in components that are easier to reuse and test.
In this article we’ll see how we can create a composable that hoists its state, while providing a default state implementation that can be used when the defaults meet our needs.
What we want to achieve is an email input field that displays an error label underneath when the email is not valid, like shown below
If the text is valid (and for this article we will consider empty as valid as well), the error label will not display and the input field will not be in an error state. For this article we’ll keep things simple and minimize the options on our composable.
First implementation with internal state
The first solution that may come to mind is to implement this composable with its own internal state that will handle email validation. The rationale here is that if you want to use this composable in multiple places you do not want to have to implement the validation at every usage of the composable, so adding the state directly in the composable might sound like a sound approach (it is not). Let’s see how we would implement such a composable with internal state:
Such a composable would be used like this:
We can only provide the initial email address and have no visibility on updates to the input field or control on the validation, making this composable rigid. Let’s take it up a notch and make the composable more flexible.
Hoisting the composable state
The next step is to hoist the state so that the composable becomes stateless and the caller is responsible for providing the state. To do so, we need to accept the values (in this case, the email address) as a parameter, and we have to expose a callback so that the caller can handle updating the state (email) whenever the user changes the input.
If we apply this principle to our composable, we end up with this result:
We have removed the state from the composable and now we are shifting that responsibility to the caller. This composable is solely responsible for displaying the content and notifying the caller of updates to the input field, any changes to state (changes to the email field, or to its validity) are now out of scope of this composable.
Now when we call the composable we need to provide the state, and the validation as well. To use this composable we would do something like this:
We now have complete control, and visibility, on the content of the email field and the validation status; this makes the composable that much more flexible as we could, for instance, implement different validation strategies for different scenarios. Our callback is notified whenever the user updates the email field, and on each update we calculate whether the email is valid. The updated email value and the result of the validation are then used in the composable to update the email field and to show or hide the
Invalid Field text field.
That said, this solution is not perfect, if we have multiple places where we want to use this composable we would need to handle the state in all those places, potentially duplicating the same logic in our app.
Is there a better way to handle this? Well, glad you asked, there indeed is, let’s see how we can both hoist our state for customization when needed, while having some defaults that we can leverage for most cases.
Default state hoisting
The first step to achieve this is to define a state
interface — let’s start with this. In our case, there are 2 items that define our state, the
Note that, using Jetpack Compose conventions, this state interface is named as the composable with
State appended to the end.
Next we will create an implementation of this interface that will provide the default validation for those cases where we do not need customization:
Let’s have a quick look:
- We define a private
mutableStateOfto hold the email value, initialized to the value we receive in the constructor, which defaults to an empty string. We expose a public property to get and set the email value; the email value is observable.
- We define a second
isValidfield. As this value is derived from the email field, we leverage the existing
derivedStateOffrom compose. This value is likewise observable.
- This is our standard email validation logic.
- We provide a saver so that the state can be stored and restored when the activity or fragment hosting our composable is recreated.
- The state will need to be
remembered, so we also have a utility function that remembers our state and uses the saver to persist its content. Note that, following standard Jetpack Compose conventions, this method is named starting with
Next we can rewrite our email composable to accept this state, and use its value to drive the UI. In other words, we are replacing the email value, the valid flag and the callback with our state. Once we refactor our composable it looks like this:
We can see that we accept a
state of type
EmailInputFieldState and we default it to our standard implementation. Now when the user updates the email input field we update the state’s email field, which in turn triggers a calculation of the email’s validity, which is then used to determine whether we should display the error field.
With this solution we do not need to handle logic at each call site if the default email validation works for us, we can just let the default state handle the input for us.
To use this widget we would add it to our composition tree as shown here:
If we want to have our custom email validation we can then create our implementation of
EmailInputFieldState and provide it to the composable.
Note that with this implementation we can also observe the value of the email field and its validity, while using the default state implementation, we just need to instantiate the state outside the composable and then provide it in the constructor. This snippet below shows how to do so, and displays a text field below the email composable that indicates what the current email address is, and whether it is valid:
We use the provided
rememberEmailInputFieldState method to obtain an instance of the state, then we provide this state to the composable and use it as well to display the current value and its validity.
With this solution we have the best of both worlds, we have a composable that can be used as-is and will handle state itself, while allowing us to observe said state, and offers customization options by allowing callers to supply their own state implementations for cases where the defaults do not fit our needs.
The full implementation for this composable, with sample usage, is shown below:
And this is the result: