[@xstate/vue] Run actor during setup, not mounted.
I started migrating the chat logic in a Vue application using XState. After some time, I encountered an issue where the actor does not work during the execution of the setup hook. This makes it impossible to set any initial state based on external data. Here's a simplified example for context:
const { send, snapshot } = useActor({
// ...
context: {
after: null,
before: null,
},
// ...more logic
states: {
idle: {
on: {
JUMP_TO_CURSOR: {
target: 'loadingAroundTop',
actions: assign({
after: ({ event }) => event.cursor,
before: null,
}),
},
}
}
}
});
const { data: conversation } = await useFetchConversation({
variables: () => ({
id: route.params.id,
}),
});
if (conversation.value.lastSeenMessageCursor) {
send({
type: 'JUMP_TO_CURSOR',
cursor: conversation.value.lastSeenMessageCursor,
});
} else {
send({
type: 'INIT_WITHOUT_CURSOR',
});
}
const { data: messagesData } = await useFetchConversationMessages({
variables: () => ({
// Variables depend on machine context
after: snapshot.value.context.after,
before: snapshot.value.context.before,
}),
});
Expected behavior:
console.log(snapshot.value.value); // 'loadingAroundTop'
console.log(snapshot.value.context.after); // <cursor>
console.log(snapshot.value.context.before); // null
Actual behavior:
console.log(snapshot.value.value); // 'idle'
console.log(snapshot.value.context.after); // null
console.log(snapshot.value.context.before); // null
After looking into the hook implementation, I realized the problem is that the actor is only started inside the onMounted() hook. This creates an unnecessary limitation because:
- It makes the actor unusable during SSR;
- It prevents reusing logic immediately in the
setup()function; - And it forces awkward workarounds just to interact with machine state early.
I don't see any strong reason for delaying .start() until onMounted(). I also found an issue mentioning the same limitation: https://github.com/statelyai/xstate/issues/3786#issuecomment-1424871839. Actor start was moved to onMounted in this commit https://github.com/statelyai/xstate/pull/4288/commits/bfc9f74a426784fe2d7a512f800a00a4c0f12fed
⚠️ No Changeset found
Latest commit: 73ac8e965d9f6ffe21f2eba8cfdd6bdb0671090c
Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.
This PR includes no changesets
When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types
Click here to learn what changesets are, and how to add one.
Click here if you're a maintainer who wants to add a changeset to this PR
This makes it impossible to set any initial state based on external data. Here's a simplified example for context:
This would be a canonical use case for input
@Andarist Sorry for commit spam, it was wrong to use github interface first.
I just checked in my SSR and I am using KeepAlive component for chat :)
This would be a canonical use case for input
The thing is that the JUMP_TO_CURSOR shown in the example is used not only for the initial load, but also if we, for example, go to some message via search. It would be strange not to use the already written flow.
@Andarist I found another problem that existed before. Besides the fact that XState basically always remained in initial state in SSR, useSelector was missing flush: 'sync' which would not allow ref to be synchronized in SSR.
@Andarist @negezor What is the status of this PR?
@Andarist @negezor What is the status of this PR?
I believe the PR is ready to be merged, as it fixes two important issues:
- The module is now initialized immediately on the client, rather than in the
mountedhook. As I understand it, the original reason for this change was due to alistenerinuseActorRefcausing an error when trying to access an uninitializedsnapshot. - It enables the module to work in SSR, since previously the
watchwas missingflush: 'sync'. Of course, it won’t start theactorautomatically, but at least it allows you to do so manually.
- This would inherently allow for hydration mismatches to happen. Im not sure what Vue does in such cases but it’s definitely something that should be discouraged and avoided. The client-rendered first content should match the SSRed content
- I’d move this to a separate PR. This is independent from 1 and can be discussed separately
I think what's missing here is using EffectScope (https://vuejs.org/api/reactivity-advanced#effectscope) instead of onMounted and onBeforeUnmount.
This lets you add code that uses reactivity and make sure that setup and cleanup happens no matter where the composable is called, so it is no longer coupled to a component. I'm not how it works in your SSR implementation, but Nuxt uses EffectScope and scope.stop() to trigger cleanup when navigating away.
this would probably fix #4754 as well.
@ninique In any case, you need to call scope.stop() manually in SSR. But I think it makes sense, it will be easier to control side effects. Another question is whether it is worth running it again in SSR initially.
Edit: Made a draft PR if someone wants to review https://github.com/statelyai/xstate/pull/5382
@ninique I'm currently testing this out in my apps, so far it's working well :
import { effectScope, getCurrentScope, isRef, onScopeDispose, type Ref, shallowRef } from "vue"
import type {
Actor,
ActorOptions,
AnyActorLogic,
AnyActorRef,
AnyStateMachine,
ConditionalRequired,
EventFromLogic,
IsNotNever,
Observer,
RequiredActorOptionsKeys,
Snapshot,
SnapshotFrom,
Subscription
} from "xstate"
import { createActor, toObserver } from "xstate"
export function useActorRef<TLogic extends AnyActorLogic>(
actorLogic: TLogic,
...[options, observerOrListener]: IsNotNever<RequiredActorOptionsKeys<TLogic>> extends true
? [
options: ActorOptions<TLogic> & {
[K in RequiredActorOptionsKeys<TLogic>]: unknown
},
observerOrListener?: Observer<SnapshotFrom<TLogic>> | ((value: SnapshotFrom<TLogic>) => void)
]
: [
options?: ActorOptions<TLogic>,
observerOrListener?: Observer<SnapshotFrom<TLogic>> | ((value: SnapshotFrom<TLogic>) => void)
]
): Actor<TLogic> {
const actorRef = createActor(actorLogic, options)
let sub: Subscription | undefined
if (observerOrListener) {
sub = actorRef.subscribe(toObserver(observerOrListener))
}
actorRef.start()
if (getCurrentScope()) {
onScopeDispose(() => {
actorRef.stop()
sub?.unsubscribe()
})
}
return actorRef
}
function defaultCompare<T>(a: T, b: T) {
return a === b
}
const noop = () => {
/* ... */
}
export function useSelector<TActor extends Pick<AnyActorRef, "getSnapshot" | "subscribe"> | undefined, T>(
actor: TActor | Ref<TActor>,
selector: (snapshot: TActor extends { getSnapshot(): infer TSnapshot } ? TSnapshot : undefined) => T,
compare: (a: T, b: T) => boolean = defaultCompare
): Ref<T> {
const actorRefRef: Ref<TActor> = isRef(actor) ? actor : shallowRef(actor)
const selected = shallowRef(selector(actorRefRef.value?.getSnapshot()))
let sub: Subscription | undefined
const updateSelectedIfChanged = (nextSelected: T) => {
if (!compare(selected.value, nextSelected)) {
selected.value = nextSelected
}
}
if (actorRefRef.value) {
sub = actorRefRef.value.subscribe({
next: (emitted) => {
updateSelectedIfChanged(selector(emitted))
},
error: noop,
complete: noop
})
}
if (getCurrentScope()) {
onScopeDispose(() => {
sub?.unsubscribe()
})
}
return selected
}
export function useActor<TLogic extends AnyActorLogic>(
actorLogic: TLogic,
...[options]: ConditionalRequired<
[
options?: ActorOptions<TLogic> & {
[K in RequiredActorOptionsKeys<TLogic>]: unknown
}
],
IsNotNever<RequiredActorOptionsKeys<TLogic>>
>
): {
snapshot: Ref<SnapshotFrom<TLogic>>
send: Actor<TLogic>["send"]
actorRef: Actor<TLogic>
}
export function useActor(actorLogic: AnyActorLogic, options: ActorOptions<AnyActorLogic> = {}) {
if ("send" in actorLogic && typeof actorLogic.send === "function") {
throw new Error(
`useActor() expects actor logic (e.g. a machine), but received an ActorRef. Use the useSelector(actorRef, ...) hook instead to read the ActorRef's snapshot.`
)
}
const scope = effectScope()
const result = scope.run(() => {
const snapshot = shallowRef()
function listener(nextSnapshot: Snapshot<unknown>) {
snapshot.value = nextSnapshot
}
const actorRef = useActorRef(actorLogic, options, listener)
snapshot.value = actorRef.getSnapshot()
return { snapshot, actorRef, send: actorRef.send }
})
// Ensure cleanup happens when parent scope is disposed
if (getCurrentScope()) {
onScopeDispose(() => {
scope.stop()
})
}
if (!result) throw new Error("useActor: effectScope did not run correctly")
return result
}
Need more testing, but it might be worth contributing back eventually.