Skip to main content
Donovan LaDuke - Developer

Building Passive UI in Jetpack Compose


Featured in Android Weekly Issue 601

Declarative UI is often described as creating UI that is "functional" in its nature, that is to say that data goes in and UI comes out. This is wonderful in the abstract but in a world where apps without side-effects are not particularly useful to end users, how can this be accomplished? This post will look at how to apply concepts and patterns to create "Passive" or "Dumb" UI; UI that doesn't do anything except show visuals. In many ways this post is a spiritual successor to a previous post on StateHolders so it may be helpful to get a refresher on those ideas before continuing this post.

Passive UI #

In his 2006 blog post titled "Passive View" Martin Fowler introduces a concept of abstracting view logic into the controller of an MVC architecture to remove the dependency between the View and the Model, treating the Controller as an arbitrator between the two other components. Building on this idea, one can envision a ui composable that has no internal state and relies entirely on an external coordinator. That coordinator would manage and update state before sending the unified state object into the component to render. This separates the rendering logic from the state manipulation logic, giving a clear boundary or responsibilities. Here is an example to help illustrate this idea.

A note before continuing, there will be discussion of uppercase "C" Composable functions, that is Composable component functions rendered by Jetpack Compose and lowercase "c" composable functions that are functions designed to be reused and combined to share code. The casing will be the distinction as needed.

Example #

To show what is being accomplished, this example will be to work backwards from a less ideal state towards a more ideal state. To start, here is a component mixing state and rendering logic into a single component.

@Composable
fun ToggleInput() {
    var isOn by remember { mutableStateOf(false) }

    LaunchedEffect(isOn) {
        if (isOn) {
            Log.i("Toggle", "Turned Input On")
        }
    }

    val title = if (isOn) {
        R.string.toggle_state_on
    } else {
        R.string.toggle_state_off
    }

    Column {
        Text(text = stringResource(title))

        Checkbox(
            checked = isOn,
            onCheckedChange = {
                isOn = !isOn
            }
        )
    }
}

This component is doing a ton of work! It is...

The logic can be split out from the rendering so all the ui component cares about is showing the title and checkbox and calling updateToggle.

data class ToggleInputState(
    val title: String,
    val isOn: Boolean,
    val updateToggle: (Boolean) -> Unit,
)

@Composable
fun rememberToggleInputState(): ToggleInputState {
    var isOn by remember { mutableStateOf(false) }

    LaunchedEffect(isOn) {
        if (isOn) {
            Log.i("Toggle", "Turned Input On")
        }
    }

    val title = if (isOn) {
        R.string.toggle_state_on
    } else {
        R.string.toggle_state_off
    }

    return ToggleInputState(
        title = stringResource(id = title),
        isOn = isOn,
        updateToggle = { isOn = it },
    )
}

@Composable
fun ToggleInput(state: ToggleInputState) {
    Column {
        Text(text = state.title)

        Checkbox(
            checked = state.isOn,
            onCheckedChange = state.updateToggle,
        )
    }
}

Now ToggleInput is only in charge of rendering based on the current state. This has a few distinct advantages including that ToggleInput and rememberToggleInputState can now be tested in isolation and we now control the API surface available to our ui component. This pattern also has several advantages for compose previews because the component can now be easily rendered in any state desired, in this example either on or off. Additionally, the previews can be shown using a hard-coded state value or they can use the rememberToggleInputState depending on the particular use case. See the example of the improved previews below.

@Preview
@Composable
fun PreviewToggleInputWithHolder() {
    ToggleInput(state = rememberToggleInputState())
}

@Preview
@Composable
fun PreviewToggleInputWithState() {
    val state = ToggleInputState(
        title = "Test Title",
        isOn = true,
        updateToggle = {},
    )

    ToggleInput(state = state)
}

@Preview
@Composable
fun PreviewToggleInputWithStateOff() {
    val state = ToggleInputState(
        title = "Test a Different Title",
        isOn = false,
        updateToggle = {},
    )

    ToggleInput(state = state)
}

A final improvement can be made to our state holder now that it has been broken out of the ui component. The logic to manipulate the toggle state can be split out and composed with the logic to handle the side-effect and string resource loading with no change to the ToggleInput or ToggleInputState.

data class ToggleState(
    val isOn: Boolean,
    val updateToggle: (Boolean) -> Unit,
)

@Composable
fun rememberToggleState(): ToggleState {
    var isOn by remember { mutableStateOf(false) }

    return ToggleState(
        isOn = isOn,
        updateToggle = { isOn = it },
    )
}

data class ToggleInputState(
    val title: String,
    val isOn: Boolean,
    val updateToggle: (Boolean) -> Unit,
)

@Composable
fun rememberToggleInputState(
    toggleState: ToggleState = rememberToggleState(),
): ToggleInputState {
    LaunchedEffect(toggleState.isOn) {
        if (toggleState.isOn) {
            Log.i("Toggle", "Turned Input On")
        }
    }

    val title = if (toggleState.isOn) {
        R.string.toggle_state_on
    } else {
        R.string.toggle_state_off
    }

    return ToggleInputState(
        title = stringResource(id = title),
        isOn = toggleState.isOn,
        updateToggle = toggleState.updateToggle,
    )
}

Now it is even easier to test the specific functionality in isolation (e.g. rememberToggleInputState with isOn true or false and the updateToggle call from rememberToggleState). Additionally, if there is need for a toggle state elsewhere in the application, the rememberToggleState can be reused in that location instead of repeating the same lines of code over and over again.

Why Passive UI #

Hopefully the previous example has already highlighted the positive outcomes of switching to use a Passive UI. To be more explicit, here are some of the advantages of leveraging Passive UI for ui components.

Conclusion #

Passive UI has substantial advantages over cramming all the code into a single component, especially as the complexity of a given component grows. As with any new pattern, try applying the pattern in small ways to see if the positive outcomes outweigh any extra effort in implementation. To play around with the code presented above, check out the gist here. Until next time, thanks!

Did you find this content helpful?

Please share this post and be sure to subscribe to the RSS feed to be notified of all future articles!

Want to go above and beyond? Help me out at one of the services below, it goes a long way in helping run this site. Thank you in advance!

Donate with PayPal Buy me a Coffee on Ko-fi Support me on Patreon