Skip to content

Conversation

negezor
Copy link

@negezor negezor commented Jun 26, 2025

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: #3786 (comment). Actor start was moved to onMounted in this commit bfc9f74

Copy link

changeset-bot bot commented Jun 26, 2025

⚠️ No Changeset found

Latest commit: 73ac8e9

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

sub = actorRef.subscribe(toObserver(observerOrListener));
}

actorRef.start();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is this change compatible with SSR and KeepAlive?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ping @negezor

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it works in SSR and KeepAlive. I already answered this below. My production currently has a fork of @xstate/vue running for chat that uses KeepAlive to switch dialogs, page is also can be rendered on the server.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It doesn't work in SSR correctly as far as I can tell. Running side-effects directly in setup will unavoidably lead to leaks and unwanted behavior. onBeforeMount (and other lifecycle hooks) are inherently not called on the server - so the actorRef instance can't be stopped correctly.

To quote Vue docs:

Since there are no dynamic updates, lifecycle hooks such as onMounted or onUpdated will NOT be called during SSR and will only be executed on the client.

You should avoid code that produces side effects that need cleanup in setup() or the root scope of <script setup>. An example of such side effects is setting up timers with setInterval. In client-side only code we may setup a timer and then tear it down in onBeforeUnmount or onUnmounted. However, because the unmount hooks will never be called during SSR, the timers will stay around forever. To avoid this, move your side-effect code into onMounted instead.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works, but it doesn't clear the timer. I still want it to work in SSR. Alternatively, we can just check that we are in SSR typeof window === undefined and just not run .start(). The user can do this themselves. In the Nuxt conditional, we can do:

const actor = actorRef(machine);

if (import.meta.env.SSR) {
    const nuxtApp = useNuxtApp();
    nuxtApp.hook('app:rendered', () => {
        actor.value.stop();
    });

    actor.value.start();
}

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It works, but it doesn't clear the timer.

Yeah, right - I meant "it doesn't work correctly". If the timer gets started, it absolutely has to be stopped for us to consider this an appropriate solution. It's either that or not starting the actor at all within the setup function.

You also can't have a divergent behavior between the server and a client because you'd risk hydration mismatches to happen. The whole point of SSR is to deliver the same initial state of the HTML to the browser as the one that would be created client-side.

@Andarist
Copy link
Collaborator

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

@negezor
Copy link
Author

negezor commented Jun 26, 2025

@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.

@negezor
Copy link
Author

negezor commented Jun 26, 2025

@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.

}

if (typeof window !== 'undefined' && typeof document !== 'undefined') {
actorRef.start();
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now the code still won't quite work exactly the same bewteen client and the server. In the thread you have described that the current inner workings suffer from:

It makes the actor unusable during SSR;

And now... they will still work the same way on the server. So it seems one of the things you have wanted to address with this PR just can't be addressed.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At least it will definitely work now if you call actor.start() in SSR. Before this, watch had no sync. This problem cannot be solved until Vue officially adds some kind of hook for the component to finish working in SSR. Basically, all SSRs are somehow related to some specific life cycles of implementations. Nuxt has its own, my SSR has a slight mimicry of Nuxt.

I can say that I achieved the most important thing with this PR, it is the ability to run XState during setup, either on the client or on the server.

@davidkpiano
Copy link
Member

@Andarist @negezor What is the status of this PR?

@negezor
Copy link
Author

negezor commented Aug 1, 2025

@Andarist @negezor What is the status of this PR?

I believe the PR is ready to be merged, as it fixes two important issues:

  1. The module is now initialized immediately on the client, rather than in the mounted hook. As I understand it, the original reason for this change was due to a listener in useActorRef causing an error when trying to access an uninitialized snapshot.
  2. It enables the module to work in SSR, since previously the watch was missing flush: 'sync'. Of course, it won’t start the actor automatically, but at least it allows you to do so manually.

@Andarist
Copy link
Collaborator

Andarist commented Aug 1, 2025

  1. 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
  2. I’d move this to a separate PR. This is independent from 1 and can be discussed separately

@ninique
Copy link

ninique commented Aug 4, 2025

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.

@negezor
Copy link
Author

negezor commented Aug 11, 2025

@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.

@Hebilicious
Copy link

Hebilicious commented Oct 2, 2025

Edit: Made a draft PR if someone wants to review #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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants