Hello! When working with composable functions, the...
# compose
f
Hello! When working with composable functions, the preferred approach is to use a unidirectional data flow architecture, where an immutable description of the composition goes down in the composable functions parameters, and events come up through lambdas that are passed to the composable... In particular, it is important for composable functions to be idempotent. Therefore, it is an anti-pattern to use mutable state references in the parameters of a composable function because it breaks idempotency, right? There is a twitter detekt rule for it AFAIS. However, looking at the Scaffold composable function, it uses a reference to a mutable
ScaffoldState
instead of using a unidirectional data flow. Is it done for convenience (encapsulating part of state management)? Doesn't this break idempotency of the
Scaffold
function? Is this a general good practice for custom composable functions? 🙏
c
Is
ScaffoldState
really mutable? Looking at its source, I only see https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:compose/[…]arHost.kt%20class:androidx.compose.material.SnackbarHostState which is encapsulated in a MutableState, so tracked by compose
c
There are a number of classes needed by built-in widgets that seem “mutable” (rememberScrollState() is another example), but in the end the properties in those classes are backed by a
MutableState
rather than normal properties. This makes it so that those properties can be changed by the UI widgets, and it’s not much different from you defining your own
mutableStateOf()
to make your UI work. In general, no this is not something you should reach for first. It’s a case where the Compose/Material designers made the intentional decision that you, as the consumer of a particular component, don’t need/shouldn’t have precise control over the widget like you might with the
enabled
property of a button. They chose not make the property “internally mutable” so you don’t need to worry about it and mess with all the extra boilerplate. Based on how Compose and States work, it’s not technically breaking the UDF pattern, but it does add some nuance to it for the sake of ease-of-use. For your own code, the official recommendation is to lift state as you would expect, not to hide properties like many of the Material widgets do.
f
The
ScaffoldState
is mutable. The fact that it is observable through the Snapshot system and thus it works with compose it's clear. I was asking more of a conceptual question and trying to realize best practices and how much to follow them. In our project we have fairly complex UI and composable functions, so we could write some components that encapsulate their own logic and state, while still interacting with the external world through a mutable (observable) state, just like
ScaffoldState
or
rememberScrollState
. On one side, practically speaking, following the scaffold approach creates simpler implementation and code that can be resused more easily (without the ceremony of lifting the state every time), on the other side it doesn't seem to follow stricly the state lifting, and idempotent composable function approach, which is promoted by the official documentation. So there is this choice to make in our own code. We are trying to figure out the actual best practice
c
It’s best to keep things explicit in Compose, and the particular way that many of the Compose UI APIs are set up is not. It’s intentional, to keep you from being overwhelmed by boilerplate that most people don’t need to worry about, but is definitely a case where the Compose library designers are very smart and know what they’re doing, and know when it’s OK to break the rules. But that’s not to say that you can’t create components which maintain some internal state. Just be explicit about it when you do, so it’s easy to tell when a component is managing its own state. A common pattern here is to have 2 overloads for each component, a “stateless” version which exposes only properties and callbacks (the state is fully lifted), and a second which internally sets up the MutableStates to simplify usage. This keep things simple to use in your app, but also explicit about when a given function is managing state, along with an easy way to manage the state from a higher level if needed. For example:
Copy code
@Composable
fun SomeComplexFunction() {
    var stringValue by remember { mutableStateOf("") }
    var intValue by remember { mutableStateOf(0) }
    var booleanValue by remember { mutableStateOf(false) }

    SomeComplexFunction(
        stringValue = stringValue,
        intValue = intValue,
        booleanValue = booleanValue,

        updateStringValue = { stringValue = it },
        updateIntValue = { intValue = it },
        updateBooleanValue = { booleanValue = it },
    )
}

@Composable
fun SomeComplexFunction(
    stringValue: String,
    intValue: Int,
    booleanValue: Boolean,

    updateStringValue: (String) -> Unit,
    updateIntValue: (Int) -> Unit,
    updateBooleanValue: (Boolean) -> Unit,
) {
    // TODO()
}
c
Note that sharing "immutable" objects that are Snapshot-aware is recommended in Compose for domain logic, example here: https://developer.android.com/jetpack/compose/architecture#example It's not recommended for regular UI design components, however.
s
It’s not recommended for regular UI design components, however.
Regarding regular UI components actually, I encountered this case recently, where I opted to use such a
State
object, and I don’t know if I am gonna regret it or not. I had a design system component, which basically let’s say takes in a string, and exposes a lambda to update it, it basically contains a TextField, and some more things. Now, I was hoisting this information all the way up to the ViewModel for a screen, since I had to have access to it there, for some reasons. Then there were some other screens that were using this exact same design component, and again, had the requirement where this state was hoisted to the ViewModel. This wiring started to become quite cumbersome, moving it from the Viewmodel’s UiState, to the navigation-destination level composable, then to the screen-level composable, and then often to child composables too. I was doing this for 4+ screens at this point. I then thought let’s try doing what Scaffold and all these
…State
objects do, and had this design component take this one object in, which follows these guidelines like being @Stable and if it contains any mutability it’s all backed by snapshot state. Then the ViewModel also contains inside its exposed UiState, an object of that exact same type. Then passing it down to the child composable is as simple as passing this 1 object (or inside the screen-level UiState). And on the ViewModel, when I need to access it to do something it’s as simple as getting it out of that containing object. It has been quite a simplification on the code there, but I do wonder if I will go overboard with this approach.
c
I maintain the Ballast MVI library, which can be helpful in combining multiple state properties together for a screen, while still making it easy to share across the UI and explicit how the state changes. Might make things a bit cleaner and easier to work with hiding MutableStates behind interfaces, which can obscure how those values change. To the point of “I do wonder if I will go overboard with this approach”, I think in general this can be a great way to wrangle the complexity of screens, but you will want to make sure it’s clear how to update that state, not just how to share it and pass it around easily, so that it’s understandable for new developers who may need to work with the code
s
These
…State
objects, just like the ScaffoldState or
DatePickerState
from material3, usually contain a way to mutate that state by themselves, shouldn’t be hard to figure that out as I see it. And this was actually one of the reasons I begun to try this out in the first place. I had a DatePickerState which I wanted to hoist all the way up to the ViewModel, and it was the only way to read and write the date picker state using the material3 component. It received a DatePickerState parameter, which it internally mutated as you select a new date and whatnot, doesn’t give back a callback for when the state is updated. So I kinda had to take this approach there. And this worked well so I tried it out with my own components too. But again, not 100% confident in this technique atm, I might have different opinions in the future.
b
We call these state objects "state holders". You can read the full docs here https://developer.android.com/topic/architecture/ui-layer/stateholders But there is also an example in the advanced state codelab. https://developer.android.com/codelabs/jetpack-compose-advanced-state-side-effects#6
s
Yes, state holders is the right term for it. And from your perspective Ben, hoisting those all the way up to the ViewModel, exposing them as part of the UiState (The one StateFlow that the VM exposes) and then passing it down to individual composables to both use to display stuff but even to mutate internally? Like you have to do with DatePickerState with the DatePicker component. These docs either don’t mention doing this exactly, or I am missing something
f
Ok, that answered my question. i didn't find those examples showing the
...State
parameter pattern. To sum up, it's fine (and desirable) to create this state holder helper classes to simplify our composable functions. Even if this breaks the conceptual idempotency of the composable function, we don't care much because we know that our state holder is using the snapshot system anyway (aka the composable is aware of the state changes) and we help the compiler trust us by using the @Stable annotation?
b
It really depends on the situation (cop out answer I know). Most state holders are for UI logic and so hoisting them into the viewmodel feels wrong to me. In general you only want to hoist as far as the lowest common consumer and normally thats not quite as high as the viewmodel. This is what this section is trying to describe https://developer.android.com/topic/architecture/ui-layer/stateholders#choose-viewmodel
As for annotating with stable, yes state holders are normally stable because they notify the sCompose of mutations via MutableState. The compiler should pick that up itself but you can make sure it happens with the annotation
s
Then for this specific situation, where you want to have a DatePicker, and you need this state inside the ViewModel so that you can use the existing selected date and so on to derive your UiState from. If you don’t hoist it all the way up there what is your best bet? A LaunchedEffect, with a snapshotFlow in there, reading the internal date picker state and calling a function on the ViewModel which will update some other state representation that you will have in there which will be a mirror of the DatePickerState but you need to make sure to keep it in sync by yourself?
b
It's an interesting question, I haven't actually tried the new date picker yet so first off if the API is hard to use in the real world and you have any feedback do file a bug. Then to answer what I would do, it really depends, but I would think the reason you are needing it in the viewmodel is that you have to react to a date being selected and then kicking off some longer running event? In that case I probably would have done a dialog rather than an inline UI date picker and then fired the event on a confirm button. snapshot flow would also work but would react to every change which you may or may not want. Hoisting it to the viewmodel could work as well, the one thing I wouldn't do would be to expose it as part of some overall UI state stateflow as that to me sounds like stacking mutable layers which always leads to issues in my experience.
s
Thanks a lot for the response. Let’s discuss some points.
I would think the reason you are needing it in the viewmodel is that you have to react to a date being selected and then kicking off some longer running event
The reason I want it in the ViewModel is that I use the date state to • I take this date selected, and if it is not null is is passed down so that one of the UI elements will show the currently selected date (not the Dialog in this case, but think like a row item which shows what you’ve selected when you used the dialog). • If there is a date selected, I want to show more fields (it’s a screen that works kinda like a form to entry stuff), so this means that the UiState needs to change to reflect that these new items should show on the screen • I also want to use this at the end of entering everything, to perform a network request, and yes this one I understand that I could have the local date picker state in the composable, and on the button click take that current local state and pass it to the lambda which would eventually call the VM. But I am mostly interested in the first two points. So then we go to
Hoisting it to the viewmodel could work as well, the one thing I wouldn’t do would be to expose it as part of some overall UI state
And I wonder, would it then have to be part of another StateFlow that the ViewModel is exposing, so you’d have both of these as parts of the VM’s API? And if yes, what happens if it’s existence relies on the other UiState being in a specific situation? Think like:
Copy code
sealed interface SomeUiState {
  object Loading: SomeUiState
  data class Content(foo: Foo): SomeUiState
}
And the date picker state only is applicable when the
SomeUiState
is
Content
but not otherwise. And instead of a simple loading/content situation think of a more complex UI where it has multiple distinct “States” that the entire UI is in which would render quite dinstinct UIs depending on it. This would also be super inconvenient as you’re polluting the public VM API with something that is not always applicable. If on the other hand it is part of the Content, like let’s say
Copy code
sealed interface SomeUiState {
  object Loading: SomeUiState
  data class Content(foo: Foo, datePickerState: DatePickerState): SomeUiState
}
Then you get it only when it’s applicable. With the downside that then you got a
StateFlow<SomeUiState>
which internally holds the
DatePickerState
which is also mutable internally, meaning that the StateFlow internal item may be mutated without the flow re-emitting. Which well, isn’t a problem (I guess?) when consuming this from compose, but only if you are compose-only. Which in my case is the case anyway. Would love to hear more of your thoughts around this!
b
First part, that all sounds like UI logic to me so I would try to have that all in my composables probably hoisted all the way to the top screen level composable, but not to the viewmodel. I would then pass the event to the viewmodel when it was actually confirmed like you said. Why do you need it in the viewmodel for your first two bullets? And for the second part, I don't think there would even be a need for a StateFlow. The compose state objects are your state flow in this case. And for polluting the public API, that's part of the reason why I don't like hoisting that far.