Skip to content

[@xstate/vue] Run actor during setup, not mounted. #5311

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 6 commits into
base: main
Choose a base branch
from

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.

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

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.

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.

3 participants