Open IonianPlayboy opened 8 months ago
The idiomatic way to achieve this would be to hoist your machine to some common parent and then pass down the created actorRef
using the provide/inject API ( https://vuejs.org/guide/components/provide-inject ). useMachine
/useActor
starts separate instances so that's not what you are looking for.
I'll use a React code snippet here, since that's what I'm familiar with:
const counterMachine = setup({}).createMachine({ /* ... */ })
const First = () => {
useActor(counterMachine)
return null
}
const Second = () => {
useActor(counterMachine)
return null
}
const App = () => {
return (
<>
<First />
<Second />
</>
)
}
This application doesn't run a single machine but rather two machines - each one is scoped to its own component.
The idiomatic way to achieve this would be to hoist your machine to some common parent and then pass down the created
actorRef
using the provide/inject API ( https://vuejs.org/guide/components/provide-inject ).
Indeed, this would be a suitable workaround and do exactly what I need without any changes needed in @xstate/vue
. I overlooked this alternative because I'm much more used to use hooks instead when possible, and this is less ideal for me since it would force me to use two different ways of interacting with machine depending on if they should be shared or not, but it would work just fine.
useMachine
/useActor
starts separate instances so that's not what you are looking for. [...] This application doesn't run a single machine but rather two machines - each one is scoped to its own component.
I understand that and I never expected that simply using useMachine
/useActor
out of the box would create a shared machine/actor across components. I guess I did not make it clear in the issue that I was wrapping the useMachine
hook inside a function that would make it shareable, so to better reflect my intent your React snippet would look more like this :
const counterMachine = setup({}).createMachine({ /* ... */ })
const useSharedCounterActor = () =>
createSharedHook(() => useActor(counterMachine));
const First = () => {
useSharedCounterActor();
return null;
};
const Second = () => {
useSharedCounterActor();
return null;
};
const App = () => {
return (
<>
<First />
<Second />
</>
);
};
Now First
and Second
would share access to the same instance of the counter machine, instead of having each their own separate instance.
In Vue, this is possible thanks to effect scopes. To sum it up, every reactive effects (ref
, computed
, watch
, etc...) are bound to a scope, and are disposed/collected when the scope is terminated. This is how Vue keep track of everything and knows when it should stop and garbage collects what's not needed anymore.
By default, each component define its own scope, so reactive effects are disposed when their parent component is unmounted. However, the effectScope
API allows to define a custom scope that is not bounded to a particular component, and it can be used to create a shareable hook that will keep track of the places where its reactive effects are needed (i.e its suscriber), and can stop itself when all the related components have been unmounted.
There is an example of how it can be implemented in the RFC that introduced this API if you want to learn more about it, but what's important here is that creating shareable hooks is possible in Vue 3.2.x and beyond. There is even an utility function provided by a popular Vue library that allow to do exactly that, without having to code the logic yourself.
That's why I expected at first that by wrapping useMachine
/useActor
inside a createSharedComposable
utility, I would be able to reuse the same machine across components. However, because useActorRef
calls directly onBeforeUnmount
, its reactive effects are stopped independently from its parent scope, which breaks the shared instance when a component unmount, for example when changing routes. On the repro, the "fixed" version of the machine hook/composable uses a patched useActorRef
that implements the effectScope
API , which is why it is working as intended vs the out-of-the-box version.
Personally, I still consider the current behavior of @xstate/vue
to be a bug, or at least unexpected behavior, but as you pointed out a workaround is available. I would understand if you considered that it makes fixing this issue a noop or at least not your priority, especially since it would require to upgrade the vue version to at least 3.2.x, which is a breaking change.
I hope that I was able to clear up any misunderstanding and that the explanations I gave were clear enough, if that's not the case feel free to ask me any precisions you need.
Thank you for your time and for maintaining this project. 😊
Unless I'm missing something, provide/inject
is easy until you want to add type safety to it, which causes some indirections that add boilerplate and in the end force you to type-cast something (either the injection key and/or the provided value) :
// keys.ts
import type { InjectionKey } from 'vue'
import { Actor } from 'xstate'
import { authMachine } from './actors/authMachine'
export const authKey = Symbol('auth') as InjectionKey<Actor<typeof authMachine>>
// Parent component
import { authMachine } from 'src/actors/authMachine'
import { authKey } from './keys'
import { provide } from 'vue'
const { actorRef } = useActor(authMachine.provide(/* ... */)
provide(authKey, actorRef)
// Child component
import { authKey } from 'src/keys'
import { inject } from 'vue'
const throwError = () => { throw new Error() }
// typeof inject(authKey) is Actor<typeof authMachine> | undefined
// which is not usable, so I have to narrow it like this :
const authActor = inject(authKey) || throwError()
Please correct me if I'm doing something wrong here, but IMHO an API using shared composables would be better suited and more appealing to existing Vue developers, as proposed by @IonianPlayboy.
Please correct me if I'm doing something wrong here, but IMHO an API using shared composables would be better suited and more appealing to existing Vue developers, as proposed by @IonianPlayboy.
I would gladly welcome a contribution for this.
Unless I'm missing something, provide/inject is easy until you want to add type safety to it, which causes some indirections that add boilerplate and in the end force you to type-cast something (either the injection key and/or the provided value)
To be fair, that's kinda on Vue's APIs. I agree that this is somewhat awkward and I prefer how the same thing is done in React.
In @xstate/react
we have createActorContext
that binds a couple of pieces together (type-wise). A similar approach could be used here - that would hide inject
/provide
dance behind the scenes.
Please correct me if I'm doing something wrong here, but IMHO an API using shared composables would be better suited and more appealing to existing Vue developers, as proposed by @IonianPlayboy.
That's somewhat similar to createActorContext
idea. I'm not sure how the @IonianPlayboy's idea would be implemented though. It refers to createSharedHook
but it doesn't go into its details and - from what I can tell - this is not something that Vue provides.
@IonianPlayboy First of all thank you for your vueuse suggestion. I was just about to give up on xstate vue. Sharing a machine across components seems like a real necessity, at least for me. I'm just trying to think about the future though and if I will eventually run into the same issues as you. Can you explain why your machine stop prematurely? I'm new to xstate. Is that something that can happens as part of the normal lifecycle. Thanks again
XState version
XState version 5
Description
I need to use the same instance of a machine across different components, because the machine serves as the source of truth for the state of a core business logic in my app. The most idiomatic way of doing this in Vue (to my knowledge) would be to create a custom hook wrapping the base
useMachine
invocation withcreateSharedComposable
, and then to use this custom hook everywhere I need to have this shared instance.This is sadly not working currently in a couple of scenarios, because the machine is stopped prematurely and won't restart afterwards. It's really a huge pain point for me, since it means that currently I can't use
@xstate/vue
as soon as my needs start to grow too big for its scope.Expected result
I have created a reproduction to highlight the issue, with the expected behavior showing on the right panel (named
FixedPageViews
) :Since our machine only tracks page views for the child routes of the
PagesToDiscover
page, currentlyFixedPageViews
should only display its name, with no counter of page views below.FixedPageViews
should now be displaying that the page you just visited has been viewed once.FixedPageViews
should be displaying the correct count of views for each page you clicked.FixedPageViews
should correctly have reset to its initial state, since we unmounted thePagesToDiscover
component before going back toHome
.Since it is back to its initial state, you should be able to restart this flow from the start and observe the same results.
Actual result
The current behavior is showed in the repro on the left panel (named
PageViews
) :Since our machine only tracks page views for the child routes of the
PagesToDiscover
page, currentlyPageViews
should only display its name, with no counter of page views below. This is the correct behavior.PageViews
should now be displaying that the page you just visited has been viewed once. This is the correct behavior.PageViews
does not update at all from this point, since the machine has already been stopped. This is not what is expected. If you open the console, you can see the warning and errors sent by XState about it :PageViews
should correctly have reset to its initial state, since we unmounted thePagesToDiscover
component before going back toHome
. This is the correct behavior.Since it is back to its initial state, you should be able to restart this flow from the start and observe the same results.
Reproduction
https://stackblitz.com/edit/github-mwzbpb?file=src%2FpageViewsMachine.ts
Additional context
I have investigated to understand where the issue is coming from, and the culprit is the current implementation of
useActorRef
in@xstate/vue
:https://github.com/statelyai/xstate/blob/ca7f090d5f8693546452ca18f30c042012d18154/packages/xstate-vue/src/useActorRef.ts#L23-L33
The use of
onBeforeUnmount
here create a tight coupling between the machine instance and the lifecycle of the component that use it. In the reproduction, when we switch from a child route to another one, the first child is unmounted before we can go to the next page, which will stop this machine instance indefinitely.The more straightforward solution to this problem to me is the one I used in the reproduction for the
fixedPageViews
: replacing theonMounted
/onBeforeUnmount
byeffectScope
/onScopeDispose
, which is intended to replace the explicit component lifecycle hooks when used in a composable.More details on these APIs are available in the related implementation PR and RFC:
The main problem with this solution is that it would technically introduce a breaking change, since these APIs are only available for Vue ^3.2.0 and currently XState is supporting the ^3.0.0 range.
I guess that could be an opportunity to upgrade the installed Vue version in the repo, since I believe I have seen in the source code that the global JSX declaration from Vue was causing some issues, and it has been removed in 3.4.
If this is not a big enough concern to prevent fixing this bug, I would be interested in opening a PR to propose my solution if that's okay.