Updated June 2026. Tested on Vue 3 with Vuex 4 and <script setup>. Part of the Techalyst Vue 3 series.

Vuex was the standard state store for Vue for years, and plenty of existing Vue 3 apps still run on Vuex 4. If you are maintaining one, you need to understand its flow: a strict, one-direction cycle of state, getters, mutations, and actions. New projects should reach for Pinia instead, but here is how Vuex works and how to use it from the Composition API.

The four concepts

Vuex is a single shared store with four moving parts, and the separation between them is the whole point.

Concept Role Sync? Triggered by
State The shared data
Getters Derived values from state
Mutations The only way to change state Must be synchronous commit()
Actions Async work, then commit a mutation Can be async dispatch()

The rule that trips everyone up at first: only mutations may change state, and they must be synchronous. Anything async (an API call) goes in an action, which then commits a mutation with the result. This indirection is what makes state changes traceable in the devtools.

Setting up a store

// store/index.js
import { createStore } from 'vuex'
import axios from 'axios'

export default createStore({
  state() {
    return { count: 0, city: 'Colombo' }
  },
  getters: {
    doubled(state) {
      return state.count * 2
    },
  },
  mutations: {
    increment(state, amount) {
      state.count += amount
    },
  },
  actions: {
    async loadCount({ commit }) {
      const { data } = await axios.get('/api/count')
      commit('increment', data.value) // action commits, never mutates directly
    },
  },
})
// main.js
import store from './store'
createApp(App).use(store).mount('#app')

use(store) makes it available everywhere. Note state is a function, the same factory rule as Pinia, so the store starts clean.

Using the store in the Composition API

In <script setup> there is no this.$store. You reach the store with the useStore composable, and you wrap state and getters in computed so they stay reactive and synced.

<script setup>
import { computed, onMounted } from 'vue'
import { useStore } from 'vuex'

const store = useStore()

const count = computed(() => store.state.count)
const doubled = computed(() => store.getters.doubled)

function add() {
  store.commit('increment', 1) // mutation
}

onMounted(() => {
  store.dispatch('loadCount') // action
})
</script>

<template>
  <p>{{ count }} (doubled: {{ doubled }})</p>
  <button @click="add">Add</button>
</template>

Use computed rather than ref for store values, a ref would copy the value once and then drift out of sync when the store changes. You commit mutations and dispatch actions directly on the store, which keeps it obvious that a Vuex operation is happening.

Two rules worth remembering

Pass multiple values as one object. Both commit and dispatch accept a single payload, so wrap several values together:

store.commit('updateUser', { name: 'Aisha', role: 'admin' })
store.dispatch('search', { city: 'Kandy', units: 'metric' })

Commit before dispatch when sequence matters. Because mutations are synchronous, committing first guarantees the state is updated before an action reads it:

store.commit('changeCity', newCity)  // sync, applied immediately
store.dispatch('fetchWeather')       // action now reads the updated city

Modules for larger stores

As a store grows, split it into namespaced modules so names do not collide.

const cart = {
  namespaced: true,
  state: () => ({ items: [] }),
  mutations: { add(state, item) { state.items.push(item) } },
}

export default createStore({ modules: { cart } })

You then address them with the module prefix:

store.commit('cart/add', item)
store.state.cart.items

Enable namespaced: true on every child module (the root module has no name, so namespacing it does nothing).

When to move on

Vuex 4 is solid, but it is in maintenance mode and its ceremony (separate mutations, commit/dispatch strings, verbose module namespacing) is exactly what Pinia removes. For a new Vue 3 app, start with Pinia. For an existing Vuex app, the flow above is what you maintain, and a migration to Pinia can be done store by store when you are ready.

Wrapping up

Vuex enforces a one-way cycle: components dispatch actions, actions commit mutations, mutations change state synchronously, and getters derive values from it. In the Composition API reach the store with useStore and wrap reads in computed. Remember to pass multiple values as a single object and to commit before dispatch when order matters. Understand it to maintain existing apps, but choose Pinia for anything new.

More in the series: state management with Pinia and provide and inject. Questions welcome below.