<@UHAJKUSTU> I am searching for some convenient ap...
# mvikotlin
e
@Arkadii Ivanov I am searching for some convenient approach for decomposition of a store into smaller parts. For example I have a SignIn flow with 4 screens. 2 screens are basically dialogs and don’t have any UI logic, but 2 other screens for entering a phone number and an OTP code have it’s own validation. I have a SignInStore which now contain: 1. Validation of the phone number 2. Validation of the OTP code 3. Actual logic of requesting OTP code from the backend 4. Actual logic of sending the OTP code to the backend, getting session or error and the corresponding error handling I already tried 2 approaches: 1. I have one single SignInStore and a single decompose SignInComponent, which is attached in the compose with 4 different screens and passes UI events as intents to the store and maps the store state to the UI state. a. pros: All sign-in logic in one place, low amount of boilerplate code in components b. cons: If we have just one component for all screens we also have just one fat UiModel which is passed to the compose screens. As a workaround in a single component we can create 4 separate UiModels for each screen. 2. I have one single SignInStore, one SignInRoot component and several decompose child components (one for each screen). a. pros: Not much, just a separation of UiModels inside child components. Potentially some logic could also be done in the child components, but it will probably overcomplicate the system, because we already have another source of truth: the store. b. cons: A lot of boilerplate, because all child components have at least 2 files and all they do is passing events directly to the store the same way as it will work in case1. Now potentially I would like to be able to break the SignInStore into 3 parts: a. The parent store, which does the communication with the backend and handles the flow b. The phone number store, which just contains UI logic for phone validation (actually it also will load countries list etc) c. The OTP code validation store, which contains UI logic for OTP screen. Right now I didn’t find a good approach for performing such decomposition. The most complicated part is the communication between the parent store and 2 child store with UI logic. By communication I mean mainly propagation of the state from parent to children (we don’t want to have several sources of truth, right?), and the Intents from children to parent, etc. In this particular case this could not look worth the efforts, but it is just an example. I already had a similar case, but with much more complex screens with their own logic and the general logic of the flow, when each state used some part of the bigger flow state. That case we handled with MVICore by moving child stores inside the parent store and delegating some parts of the parent state to this children. It was complicated and hard to debug, but still better than monolithic store. I expected that decompose should help to solve this task, but I don’t realise yet how this should look in general. I can move validation logic into the child components instead of child stores, but it doesn’t help much with synchronization and communication. At least it creates 3 separate states (in store and in 2 child components) which I don’t understand how to sync. Did you ever tried to do anything similar?
a
I would create a component for every sign-in screen in the flow, plus the parent SignInComponent communicating between the children and with the backend. For simple components you can reduce the boilerplate by allowing UI to communicate with the Store directly. I.e. the UI can display the state directly (without a UI model), and also send Intents directly to the Store. In this case you can either let your component expose the state and accept Intents. Or you can even use the following trick.
Copy code
interface PhoneComponent : Store<Intent, State, Nothing> {

    sealed interface Intent {
        data class SetPhone(val phone: String) : Intent
    }

    data class State(val phone: String = "")
}

class DefaultPhoneComponent(
    componentContext: ComponentContext,
    storeFactory: StoreFactory,
) : PhoneComponent, Store<Intent, State, Nothing> by componentContext.instanceKeeper.getStore(
    factory = {
        storeFactory.create(
            name = "PhoneStore",
            initialState = State(),
            reducer = { reduce(it) },
        )
    },
)

private fun State.reduce(intent: Intent): State =
    when (intent) {
        is Intent.SetPhone -> copy(phone = intent.phone)
    }

@Composable
fun PhoneContent(component: PhoneComponent) {
    val state by component.stateFlow.collectAsState()
    
    TextField(
        value = state.phone,
        onValueChange = { component.accept(Intent.SetPhone(phone = it)) },
    )
}
e
Wow, so if I understood correctly you mixed a store with a component in one object. That is pretty elegant solution for reducing boilerplate and not creating unnecessary boundaries and mappers. But this doesn’t solve the problem with integration between this state parts. Or maybe I didn’t fully understand your idea. For example: 1. I have Store-Component
A
for EnterPhone screen and it’s state, containing phone number (and some other things like is number invalid, is the submit button enabled and so on). 2. Next there is store-component
B
for the next EnterOtpCode screen, which state contains OTP code and things related to it’s validation, but also it contains the phone number, because it should be displayed in the UI and needs to be the part of the UI model. 3. The parent store
P
which as you said will communicate with the backend also needs the phone number in its state, at least because it should pass this phone number to screen/store
B
and to some other screens like “Resend Code” screen. So I would like to have some sort of synchronisation between this components in terms of state.
a
Most likely I would implement this as follows. EnterPhone component would contain the phone text as part of its state. And accept a callback once the phone number is confirmed by the user.
Copy code
interface PhoneComponent {

    val model: StateFlow<Model>

    fun onPhoneChanged(phone: String)

    data class Model(
        val phone: String,
    )
}

class DefaultPhoneComponent(
    private val onConfirmed: (String) -> Unit,
) : PhoneComponent {
    // ...
}
Also EnterOtpCodeComponent in a similar way.
Copy code
interface EnterOtpCodeComponent {
    val model: StateFlow<Model>

    fun onCodeChanged(code: String)

    data class Model(
        val phone: String,
        val code: String,
    )
}

class DefaultEnterOtpCodeComponent(
    private val phone: String,
    private val onConfirmed: (String) -> Unit,
) : EnterOtpCodeComponent {
    // ...
}
And then the parent just switching the children. Once
DefaultEnterOtpCodeComponent#onConfirmed
is called, the parent has both the phone and the code, and can perform further steps.
And you can utilise the trick with delegation described above.
e
I also tried to make it work this way. But I have one concern. We can imagine some race condition here. Probably not in this particular case but in general we may want to sync not only child state to parent but also visa versa. And If both stores will perform some async operations leading to changes in the state it becomes very complicated. Also from my point of view it breaks the UDF principles because we no more have only one source of truth here. Thereby ideally there should be only one place actually storing the state (it’s shared part) and in other places this state should be delegated for reading and for changing.
a
Yes. This particular case looks like a simple step-by-step flow, which should work perfectly when each component hoists its own changeable state. Once a step is finished, the result is sent to the parent, where it's saved and switched to the next step. If you need to change the state from multiple places, then it's still recommended to hoist the state in one place. You can hoist the state in a child, and you can read and change the state from the parent as well. Or you can hoist the state in the parent, and pass the state observable to the child.
e
Thanks for ideas and discussion. I will try it in the real code and will share how it turned out. 🙂
👍 1
a
When you push OTP after Phone, the phone component remains on the stack. So you may not even need to save the phone number in the parent, as it's still available for you.
e
How should I get it in another child?
a
You just pass it via configuration->constructor
e
Ah, got you idea
decompose 1
Unfortunately, I have to say that this also probably doesn’t work well. I made the EnterPhone component-store. And I am trying to just store the phone number in that component to avoid it’s synchronisation. Just passing the number directly from the root store to other screen components where it’s needed. But if I want to keep the REST API logic in the root store it again becomes very complicated. I pass the output message from the phone component to the root component when the user clicks Submit. And I can perform the network/API operation in the root component. But I need to mutate the state of the screen corresponding to that process: show the loading animation and stop it on error, showing the error. This means I need a mechanism for changing child state from the parent. So all my efforts doesn’t make much sense, since it looks like I have to move the API logic to the EnterPhone store-component. Otherwise I will need to create another level of complexity by adding special Interface for feedback from the root store/component to child one. I absolutely can make this interface. But I suppose it will be too complicated through.
a
You can mutate the child state from the parent. Just call a method of the child component. But, I think I would perform the REST API call directly in the child component. And call the parent only once everything is clear. I assume that call is specific for the entering phone step. The idea is to make the phone component as standalone as possible.
e
I can easily imagine some component which has it’s own UI logic and used in several parts. But the particular action could be different.
a
Based on all other apps, I assume that the phone screen has its own UI (in fact a dedicated screen), with its own logic, a REST request, and then switch to the next step. In fact, a Sign In flow is usually a perfect use case for Decompose.
Unless your use case is specific and makes my assumptions void. 😀
e
I think that you are right and it should be a good fit. The issue most probably is in my head. Either overcomplicating things or not fully used to the concept. 😀
👍 1
@Arkadii Ivanov I am still in the process of implementing the flow. In my case I provide the phone number from phone component to the OTP component as a constructor parameter. And it works fine the first time. But if the user goes back, changes the number and the OTP component is restarted it takes the OtpStore from the InstanceKeeper. And the InstanceKeeper is getting old instance of the store, that is wrong. As I understand I need to change the lifetime of this instance keeper. I can call
instanceKeeper.remove(EnterCodeStore::class)
manually in exit points of the component. Is there any better way to do that? I am getting the store with the following code:
Copy code
private val store = instanceKeeper.getStore<EnterCodeStore> {
        object : EnterCodeStore, Store<Intent, State, Nothing> by storeFactory.create(
            name = "EnterCodeStore",
            initialState = State(
                registrationProgressPercent = registrationProgressPercent,
                country = country,
                phoneNumber = phoneNumber
            ),
            reducer = { reduce(it) },
            executorFactory = ::ExecutorImpl,
        ) {}
    }
a
If you
pop
the OTP component when the user goes back, everything should automatically work as you expect, as long as you properly supply the child ComponentContext.
The InstanceKeeper.Instance should have its onDestory callback called when the component is popped.
e
I just pass parent component context to the OTP component, which is probably wrong..
a
Yep, you should pass the child context provided by the factory function for you
e
Makes sense! Thank you! I didn’t realise that there is another parameter for that in the factory function. In this case I have one more related question. It is is about storing of the child component inside the root component (to access it’s state from the root). Right now I just introduced the local val:
Copy code
private val enterPhoneComponent by lazy { createEnterPhoneNumberComponent() }
And now
createEnterPhoneNumberComponent
function receives a parameter for ComponentContext. I should replace val with
lateinit var
or is there any better approach? I didn’t find any handy function for accessing a child in the stack by it’s type. Should I create one? I am not sure if it’s the right direction. 🙂
e
That worked, thank you! Is there any better approach for accessing the child component rather than just storing it in the local variable?
a
I think you have to store a permanent child component in a variable. This is the only way of accessing it later. A child component from Child Stack can be accessed via
Value<ChildStack>
returned by
childStack
function.
👍 1