Stately

Compare to Pinia

This page compares XState Store with Pinia.

Summary

TopicPiniaXState Store
State modelVue stores with state, getters, and actions.Framework-neutral stores with context, transitions, and atoms.
UpdatesActions mutate or replace Vue state.Typed events run transition functions.
EffectsActions and Vue composables/watchers.Transition-enqueued effects and emitted events.
RenderingIdiomatic Vue reactivity.Vue adapter over a framework-neutral core.
Main advantageBest fit for Vue conventions.Portable store model across frameworks.

State Model

Pinia is the idiomatic Vue store library. Option stores define state, getters, and actions; setup stores use Vue refs, computed values, and functions.

XState Store defines context and event transitions:

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment(by: number) {
      this.count += by;
    },
  },
});
const store = createStore({
  context: { count: 0 },
  on: {
    increment: (context, event: { by: number }) => ({
      count: context.count + event.by,
    }),
  },
});

The main distinction is not raw capability. It is integration style: Pinia feels like Vue; XState Store feels like an evented store that can be used from Vue.

Migration tip: Pinia state usually maps to store context; simple refs in setup stores may map better to atoms.

Update Model

Pinia actions are methods on the store. They can mutate state directly in Vue's reactive model.

XState Store updates go through named events:

const counter = useCounterStore();

counter.increment(1);
store.trigger.increment({ by: 1 });

This makes updates more explicit and portable. Pinia actions are more idiomatic for Vue developers.

Migration tip: A Pinia action usually becomes an on transition, and its parameters become the event payload.

Effects Model

Pinia effects usually live inside actions, watchers, composables, or plugins.

XState Store effects are enqueued from transitions:

actions: {
  async save() {
    await api.save(this.count);
    this.saved = true;
  },
}
const store = createStore({
  context: { saved: false },
  on: {
    save: (context, event, enqueue) => {
      enqueue.effect(() => {
        console.log('saved');
      });

      return { saved: true };
    },
  },
});

This keeps the side effect attached to the event transition while keeping the transition synchronous.

Integration And Rendering

Pinia is the better fit when you want the most idiomatic Vue experience: defineStore, setup stores, Vue devtools integration, storeToRefs, and Vue plugin conventions.

XState Store's Vue adapter exposes store snapshots as refs:

<script setup lang="ts">
const counter = useCounterStore();
const { count } = storeToRefs(counter);
</script>
<script setup lang="ts">
import { createStore, useSelector } from '@xstate/store-vue';

const store = createStore({
  context: { count: 0 },
  on: {
    inc: (context) => ({ count: context.count + 1 }),
  },
});

const count = useSelector(store, (state) => state.context.count);
</script>

Choose the adapter when you want the same store model to work outside Vue too.

Migration tip: Pinia getters map to store.select(...), adapter useSelector(...), or derived atoms depending on whether the value belongs to a store or atom graph.

Extensions

Pinia's extension model is Vue plugin-oriented, and it benefits from Vue ecosystem conventions.

XState Store extensions are store-oriented:

  • persistence
  • undo/redo
  • reset
  • schema validation
pinia.use(({ store }) => {
  store.$subscribe(() => {
    localStorage.setItem(store.$id, JSON.stringify(store.$state));
  });
});
const store = createStore({
  context: { count: 0 },
  on: {},
}).with(persist({ name: 'counter' }));

Types And Runtime Contracts

Pinia has strong TypeScript inference from state, getters, and actions.

XState Store infers transition event payloads, supports Standard Schema metadata for context/events/emitted events, and can validate schemas at runtime.

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment(by: number) {},
  },
});
const store = createStore({
  schemas: {
    events: {
      increment: z.object({ by: z.number() }),
    },
  },
  context: { count: 0 },
  on: {},
}).with(validateSchemas());

When To Choose Pinia

Choose Pinia when:

  • the app is Vue-first
  • idiomatic Vue store ergonomics are the priority
  • you want Vue devtools/plugin conventions
  • actions and getters match the team's mental model

When To Choose XState Store

Choose XState Store when:

  • you want a framework-neutral store that Vue can consume
  • updates are best modeled as named events
  • you want emitted events, store.can.*(...), or schema validation
  • the same state model should work across Vue and non-Vue code

Migrating from Pinia

Concept map

PiniaXState Store
statestore context
getterstore.select(...), useSelector(...), or derived atom
actiontransition in on
action parametersevent payload
pluginextension or explicit integration code

Before

export const useCounterStore = defineStore('counter', {
  state: () => ({ count: 0 }),
  actions: {
    increment(by: number) {
      this.count += by;
    },
  },
});

After

const store = createStore({
  context: { count: 0 },
  on: {
    increment: (context, event: { by: number }) => ({
      count: context.count + event.by,
    }),
  },
});

store.trigger.increment({ by: 1 });

In Vue components, subscribe through the Vue adapter:

<script setup lang="ts">
const count = useSelector(store, (state) => state.context.count);
</script>

On this page