Contents
  1. The Store and Its Four Concepts
  2. Mutations and Why They Must Be Synchronous
  3. Actions for Asynchronous Work
  4. Why Direct State Mutation Is Forbidden
  5. Module Namespacing
  6. When Vuex Is Overkill
  7. What You Can Do Now
← All posts

Centralised State in Vue 2: How Vuex Works and When to Reach for It

Vuex gives Vue 2 applications a single, reactive source of truth with explicit rules for how state can change. Understanding its store structure, the boundary between mutations and actions, and the cost of module namespacing determines whether it belongs in a given project.

As Vue 2 applications grow beyond a handful of components, passing state through props and events starts to break down. A value needed by a sidebar, a header, and a modal panel simultaneously cannot be owned cleanly by any one of them, and threading it through intermediate components that have no interest in it creates coupling without benefit. Vuex addresses this by placing shared state outside the component tree entirely, in a single reactive store with enforced rules about how that state can be changed.

The Store and Its Four Concepts

A Vuex store is an object registered on the root Vue instance. Once registered with the store option, every component in the tree can access it as this.$store. The store is built from four cooperating concepts: state, getters, mutations, and actions.

State is the raw data the store holds, expressed as a plain object. Getters are computed properties derived from state, cached by Vue’s reactivity system and re-evaluated only when their dependencies change. Mutations are the functions that modify state. Actions are the functions that coordinate work, including asynchronous work, before ultimately committing mutations.

import Vue from 'vue'
import Vuex from 'vuex'

Vue.use(Vuex)

const store = new Vuex.Store({
  state: {
    count: 0,
    todos: []
  },
  getters: {
    doneTodos: state => state.todos.filter(t => t.done),
    doneTodosCount: (state, getters) => getters.doneTodos.length
  },
  mutations: {
    increment (state) {
      state.count++
    },
    setTodos (state, todos) {
      state.todos = todos
    }
  },
  actions: {
    async fetchTodos ({ commit }) {
      const todos = await api.getTodos()
      commit('setTodos', todos)
    }
  }
})

Components access state and getters through computed properties and trigger changes by calling this.$store.commit or this.$store.dispatch. Vuex also provides mapState, mapGetters, mapMutations, and mapActions helpers that bind store members to component options with less boilerplate.

Mutations and Why They Must Be Synchronous

Mutations are the only place where state is allowed to change. Each mutation handler receives the current state as its first argument and an optional payload as its second. Calling store.commit('increment', 10) invokes the handler synchronously, updating the state and notifying any components that depend on it.

mutations: {
  increment (state, n) {
    state.count += n
  }
}

// In a component
this.$store.commit('increment', 5)

The documentation is explicit on one constraint: mutation handlers must be synchronous. The reason is that Vuex devtools capture a snapshot of state before and after every mutation. If a mutation contains an asynchronous callback, the snapshot is taken before the callback resolves, and the actual state change happens outside the recorded window. The devtools have no reliable way to associate the state change with the mutation that caused it, which breaks time-travel debugging and state inspection entirely.

Actions for Asynchronous Work

Actions exist to handle the cases that mutations cannot, primarily asynchronous operations such as API calls, timers, and sequences that require multiple mutations. An action handler receives a context object that exposes commit, dispatch, state, and getters. Destructuring is common.

actions: {
  async checkout ({ commit, state }) {
    const savedItems = [...state.cart]
    commit('clearCart')
    try {
      await shop.buyProducts(savedItems)
      commit('checkoutSuccess')
    } catch (e) {
      commit('checkoutFailure', savedItems)
    }
  }
}

Actions are called with store.dispatch, which always returns a Promise. This means actions can be composed: one action can await another, or a component can chain .then() on the dispatch call to respond when the work is complete.

The division of responsibility is deliberate. State changes are always synchronous and always pass through mutations, which keeps every state change traceable. Actions own the coordination layer: they decide when to commit, what to commit, and in what order, but they delegate the actual state write to a mutation. This separation is what makes the history of state changes meaningful in the devtools.

Why Direct State Mutation Is Forbidden

Nothing in JavaScript prevents code from writing store.state.count = 99 directly. Vuex does not throw an error by default. The prohibition is architectural rather than technical, and the consequence of ignoring it is practical: any state change that bypasses a mutation leaves no record in the devtools. There is no snapshot, no label, and no way to replay or undo the change. The store’s state history becomes inaccurate, and the ability to understand how the application reached a particular state is lost.

Strict mode, enabled by passing strict: true to the store constructor, converts the prohibition into a runtime error. In strict mode, any mutation to state that occurs outside a mutation handler throws immediately. This is useful during development and should be disabled in production because the check adds overhead on every state access.

const store = new Vuex.Store({
  strict: process.env.NODE_ENV !== 'production',
  state: { count: 0 }
})

Module Namespacing

As a store grows, placing all state, mutations, actions, and getters in a single flat object becomes difficult to maintain. Vuex allows the store to be divided into modules, each with its own state, getters, mutations, and actions.

const userModule = {
  state: () => ({ profile: null }),
  mutations: {
    setProfile (state, profile) {
      state.profile = profile
    }
  },
  actions: {
    async loadProfile ({ commit }, userId) {
      const profile = await api.getUser(userId)
      commit('setProfile', profile)
    }
  }
}

const store = new Vuex.Store({
  modules: {
    user: userModule
  }
})

By default, mutations, actions, and getters from all modules share the global namespace. If two modules define a mutation named reset, both handlers run when store.commit('reset') is called. This is rarely the intended behaviour. Setting namespaced: true on a module causes all of its members to be registered under the module’s path.

const userModule = {
  namespaced: true,
  state: () => ({ profile: null }),
  mutations: {
    setProfile (state, profile) { state.profile = profile }
  },
  actions: {
    async loadProfile ({ commit }, userId) {
      const profile = await api.getUser(userId)
      commit('setProfile', profile)  // resolves to user/setProfile locally
    }
  }
}

With namespaced: true, committing from outside the module requires the full path: store.commit('user/setProfile', data). Dispatching actions follows the same convention: store.dispatch('user/loadProfile', id). When a namespaced action or getter needs to reach the root store, it receives rootState and rootGetters as additional arguments, and can commit or dispatch to the root by passing { root: true } as the third argument to commit or dispatch.

The mapState, mapGetters, mapMutations, and mapActions helpers all accept a namespace string as their first argument, which scopes the binding to a specific module without requiring the full path in every call.

When Vuex Is Overkill

Vuex solves a real problem, but the problem must actually exist before introducing the tool is worthwhile. A component’s local data is the right place for state that only one component reads and updates. Props and events are the right channel for state that flows predictably between a parent and its direct children. provide and inject cover cases where a stable value needs to be available across a bounded subtree without threading it through every intermediate component.

Vuex becomes the appropriate choice when state is read and written by multiple components that have no structural relationship to each other, when the order and cause of state changes matters for debugging or auditing, or when asynchronous operations need to coordinate several state updates in a defined sequence. A shared authentication state, a shopping cart, or a notification queue are examples where those conditions are met. A form’s isSubmitting flag, a tooltip’s isVisible state, or a tab panel’s active index are not.

The Vuex documentation makes this point directly: using Vuex does not mean putting all state in Vuex. Components retain local state for anything that does not need to cross component boundaries or be tracked in the mutation history.

What You Can Do Now

Set up a minimal namespaced module and verify that strict mode catches any direct state write.

// store/modules/counter.js
export const counterModule = {
  namespaced: true,
  state: () => ({ value: 0 }),
  getters: {
    doubled: state => state.value * 2
  },
  mutations: {
    increment (state, amount = 1) {
      state.value += amount
    }
  },
  actions: {
    incrementAsync ({ commit }, amount) {
      return new Promise(resolve => {
        setTimeout(() => {
          commit('increment', amount)
          resolve()
        }, 300)
      })
    }
  }
}
// store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
import { counterModule } from './modules/counter'

Vue.use(Vuex)

export default new Vuex.Store({
  strict: process.env.NODE_ENV !== 'production',
  modules: {
    counter: counterModule
  }
})
// In a component
import { mapGetters, mapMutations, mapActions } from 'vuex'

export default {
  computed: {
    ...mapGetters('counter', ['doubled'])
  },
  methods: {
    ...mapMutations('counter', ['increment']),
    ...mapActions('counter', ['incrementAsync'])
  }
}

With strict mode active in development, attempt a direct assignment in the browser console: store.state.counter.value = 99. Vuex will throw, confirming that the mutation boundary is enforced. Then call store.dispatch('counter/incrementAsync', 5) and confirm from the devtools that the mutation counter/increment appears in the history with a clear before and after snapshot.

← All posts