Week 7 · State management with Vuex
The one-minute story
Vuex is the school notice board and the office clerk. Imagine a school where every class keeps its own copy of the timetable. When the principal changes a period, 30 classes must be told, one by one, and some will be told wrong. So the school puts one timetable on one notice board (the state). Anyone can read it (getters are like a ready-made "today's free periods" summary). But nobody scribbles on the board directly. To change it you fill a request slip (an action or a commit) and give it to the clerk (a mutation), who is the only person allowed to hold the pen. Because every change passes through the clerk, you can keep a log of who changed what, and replay it.
Vuex is that notice board for a Vue app: one shared store, read from anywhere, changed only by mutations.
By the end of this week you can:
- explain why shared state is needed and why prop drilling and an event bus break down;
- write a Vuex 3 store with state, getters, mutations, actions and modules;
- predict the output of code that mixes
commit,dispatch,asyncactions and getters; - use
this.$store,mapState,mapGetters,mapMutationsandmapActionscorrectly; - keep an auth token in the store and survive a page refresh with
localStorage; - say when not to use Vuex, and what Pinia is.
Words you need
- State - the data the whole app shares (cart items, logged-in user, token).
- Store - the single object that holds state, getters, mutations and actions.
- Getter - a read-only computed value derived from state (like a computed property for the store).
- Mutation - a plain, synchronous function that changes the state. The only legal way to change it.
- Action - a function that may be asynchronous (API calls, timers). It never changes state itself: it commits mutations.
- commit - the call that runs a mutation:
store.commit('name', payload). - dispatch - the call that runs an action:
store.dispatch('name', payload). - Payload - the one extra argument you pass to a mutation or action. Need two values? Pass one object.
- Module - a slice of the store with its own state, getters, mutations and actions. Namespaced means its names get a prefix like
cart/. - Prop drilling - passing a prop through many middle components that do not need it, just to reach a deep child.
- Event bus - an empty Vue instance used as a global message channel (
$emit/$on). - Strict mode - a development setting that complains if state is changed outside a mutation.
- Persistence - keeping data after a page refresh, for example in
localStorage. - Reactivity - Vue noticing a data change and re-rendering by itself.
Idea 1: Why shared state is needed
In a small Vue app each component owns its data. Two components that need the same data can talk through props down and events up. That is fine for parent and child. It breaks when many far-apart components need the same thing: the logged-in user, the cart, the token, the notification count.
| Approach | What goes wrong |
|---|---|
| Prop drilling | App passes user to Layout, which passes it to Sidebar, which passes it to Avatar. Layout and Sidebar never use it. Every rename touches every level. |
Event bus (new Vue() as a channel) | Anyone can emit, anyone can listen. Who changed the value? Who is listening? No central record, no devtools history, listeners leak if you forget $off. |
| Global variable | Changing it does not re-render components (not reactive). Nothing tracks who changed it. |
| Vuex store | One reactive source of truth. Every change is a named mutation that devtools can list and replay. |
State management rule. Data used by one component stays in that component (data()). Data used by many components, or that must survive navigation between routes, goes in the store. Vuex gives a single source of truth: components read from it and request changes; they do not own copies.
Short-lived UI bits (a dropdown open or closed, text half-typed in a field) are called ephemeral state. Keep them local. Putting everything in Vuex is a common over-engineering mistake (see Idea 8).
Idea 2: The one-way data flow
Vuex is built on a loop where data moves in one direction only:
Component --dispatch--> Action --commit--> Mutation --mutates--> State --renders--> Component
(user click) (async work, (sync, the only (reactive) (view updates)
API calls) writer of state)
Components never write to state. The only road to state is commit a mutation. The road to an async job is dispatch an action, which later commits.
Order for async work: dispatch an action, then the action commits a mutation, then the state changes. Wrong orders such as "mutation, then action, then state" or "state, then dispatch" are false. For a simple synchronous change you may commit straight from the component and skip the action.
Idea 3: A complete store, line by line
Vue 2 + Vuex 3 loaded from a CDN, store injected into the root instance:
const store = new Vuex.Store({
state: {
todos: [{ text: 'Learn Vuex', done: true }, { text: 'Write app', done: false }],
count: 0
},
getters: {
doneCount(state) { // like a computed property
return state.todos.filter(t => t.done).length
},
summary(state, getters) { // getters can use other getters
return getters.doneCount + ' of ' + state.todos.length + ' done'
}
},
mutations: {
increment(state, n = 1) { // (state, payload)
state.count += n
},
addTodo(state, todo) {
state.todos.push(todo)
}
},
actions: {
async incrementLater(context, n) { // context has commit, dispatch, state, getters
await new Promise(resolve => setTimeout(resolve, 1000))
context.commit('increment', n)
}
}
})
new Vue({ el: '#app', store }) // injects store as this.$store everywhere
What each part receives:
| Part | Signature | Can do |
|---|---|---|
| mutation | (state, payload) | change state, synchronously |
| getter | (state, getters) | read, return a derived value |
| action | (context, payload) | context.commit, context.dispatch, read context.state; may be async |
Using the store above (with strict: false):
store.commit('increment') // payload missing, default n = 1
store.commit('increment', 5)
console.log(store.state.count) // 6
console.log(store.getters.doneCount) // 1
console.log(store.getters.summary) // "1 of 2 done"
const p = store.dispatch('incrementLater', 10)
console.log(store.state.count) // still 6, the action is waiting 1 second
p.then(() => console.log(store.state.count)) // 16 after one second
dispatch always returns a Promise, even if your action is not declared async. The log right after dispatch shows the old value because the timer has not fired. I ran this against Vuex 3.
The payload rule: one value only
commit('name', a, b) passes only a to the mutation. The second extra value is ignored. This is a classic exam trap.
// WRONG: price is lost
this.$store.commit('update_total_cost', count, price)
// RIGHT: send ONE object
this.$store.commit('update_total_cost', { count: count, price: price })
// mutation:
update_total_cost(state, payload) {
state.total_cost = payload.count * payload.price
}
Also note the object-style commit: store.commit({ type: 'increment', n: 1 }) passes the whole object (including type) as the payload. With increment(state, n) that gives state.count += {type..., n...}, which is garbage (string concatenation). Use it only when the mutation reads payload.n.
Context versus this.$store
Inside an action the store is reached through its first argument: context.commit(...) or the destructured { commit }. Inside a component it is this.$store.commit(...). Mixing them up is the standard wrong option in "fill code1 and code2" questions: this.$store is not available inside a store action, and context does not exist in a component method.
Fill code1 (inside an action) and code2 (inside the mutation update_final) so that awardees gets the companies that have a website and capital above 40000, plus a token sent from the action.
actions: {
send_task(context) {
const token = Math.random().toString(36).slice(2)
context.commit('update_final', { min: 40000, token: token }) // code1
}
},
mutations: {
update_final(state, payload) { // code2
state.companies.forEach(company => {
if (company.website != null && company.capital > payload.min) {
state.awardees.push({ name: company.name, token: payload.token })
}
})
}
}
Reject wrong options by these rules: (1) in an action use context.commit, never this.$store.commit; (2) pass one payload object, never commit('x', 40000, token); (3) inside a mutation reach data as state.companies, never bare companies or capital; (4) push into state.awardees, not a local name; (5) read payload fields as payload.min, payload.token.
Idea 4: Mutations are sync, actions may be async
Why mutations must be synchronous. Devtools records state before and after each mutation, so you can time-travel. If a mutation changes state later inside a timer or a fetch callback, devtools cannot tell which mutation caused the change. So: mutations = synchronous, directly modify state. Actions = may be asynchronous and call APIs; they change state only by committing mutations.
// GOOD mutation
ADD_TO_CART(state, product) { state.cart.push(product) }
// BAD mutations: async code inside
ADD_TO_CART(state, product) { setTimeout(() => state.cart.push(product), 500) }
ADD_TO_CART(state, product) { fetch('/api/add').then(() => state.cart.push(product)) }
ADD_TO_CART(state, product) { Promise.resolve().then(() => state.cart.push(product)) }
Even the third one, which looks instant, is async: then runs after the current code finishes.
state: { cart: [] },
mutations: { ADD_TO_CART(state, product) { state.cart.push(product) } },
actions: {
addToCart({ commit }, product) {
setTimeout(() => commit('ADD_TO_CART', product), 500)
}
}
addItem_A(p) { this.$store.commit('ADD_TO_CART', p) }, // sync, in devtools immediately
addItem_B(p) { this.$store.dispatch('addToCart', p) }, // in devtools after 500 ms
addItem_C(p) { this.$store.state.cart.push(p) } // bypasses mutations
All three change the cart (C works in non-strict mode because Vue still sees the array change). But devtools mutation history shows only A (at once) and B (with a delay). C is invisible in history. In strict mode C logs an error. This is almost word for word a real Quiz 2 question.
Dispatch and the Promise
An action returns a Promise, so a component can wait for it:
actions: {
async addTodoAsync({ commit }, text) {
commit('addTodo', { text, done: false }) // runs right now, before the first await
return 'done'
}
}
store.dispatch('addTodoAsync', 'Learn Vuex').then(msg => console.log(msg))
console.log(store.state.todos.length)
Output: the length prints first (the commit already ran synchronously inside the async function, so it prints the new length, for example 1 if the list was empty), then done. Code in an async function runs synchronously until the first await; the .then always runs later.
Idea 5: Using the store from components
Vue.component('score-display', {
template: `
<div>
<p>Current Score: {{ score }}</p>
<button @click="boostNow">Boost Now</button>
<button @click="boostLater">Boost After 1s</button>
</div>`,
computed: {
score() { return this.$store.state.score } // read through a computed
},
methods: {
boostNow() { this.$store.commit('increaseScore') },
boostLater() { this.$store.dispatch('increaseScoreAsync') }
}
})
- Read state in a computed property (so the view updates). Do not copy it once into
data(): the copy would not follow later changes. this.$store.increaseScoreAsync()is wrong: actions are not methods of the store; usedispatch.this.$vuexstoredoes not exist; the name is$store.- Every component in the tree gets
$storeonce the root Vue instance hasstore(not only direct children).
The map helpers
They generate the repetitive code. Import them first: import { mapState, mapGetters, mapMutations, mapActions } from 'vuex'.
computed: {
...mapState(['count', 'todos']), // this.count, this.todos
...mapState({ n: 'count' }), // renamed: this.n
...mapGetters(['doneCount', 'summary']) // this.doneCount
},
methods: {
...mapMutations(['increment']), // this.increment(5) means this.$store.commit('increment', 5)
...mapActions(['incrementLater']) // this.incrementLater(10) means dispatch
}
| Helper | Goes into | Replaces |
|---|---|---|
mapState, mapGetters | computed | this.$store.state.x, this.$store.getters.x |
mapMutations, mapActions | methods | this.$store.commit(...), this.$store.dispatch(...) |
The spread ... is required when you also have your own computed properties: mapState([...]) alone returns an object, and without the spread it would replace the whole computed. A real question: the correct code is computed: { ...mapState(['stateA','stateB','stateC']) }. mapState expects the names that exist in the store state.
The store has state.score and the mutation increaseScore. Write a component computed property and a method using the map helpers.
Need a hint?
Computed uses mapState, methods use mapMutations.
Show the full solution
import { mapState, mapMutations } from 'vuex'
export default {
computed: { ...mapState(['score']) },
methods: { ...mapMutations(['increaseScore']) } // this.increaseScore() commits it
}Idea 6: Getters, reactivity and the evidence-style trace
Getters are cached computed values of the store: they recompute only when the state they read changes. They are reactive, so the claim "getters are not reactive" is false.
state: { users: [
{ id: 1, name: 'Alice', status: 'active', role: 'admin' },
{ id: 2, name: 'Bob', status: 'inactive', role: 'user' },
{ id: 3, name: 'Charlie', status: 'active', role: 'user' } ] },
getters: {
activeUsers(state) { return state.users.filter(u => u.status === 'active') },
activeUserCount(state, getters) { return getters.activeUsers.length },
adminUsers(state) { return state.users.filter(u => u.role === 'admin') }
},
mutations: {
updateUserStatus(state, { userId, status }) {
const user = state.users.find(u => u.id === userId)
if (user) user.status = status
}
}
Now store.commit('updateUserStatus', { userId: 2, status: 'active' }).
activeUsersnow holds Alice, Bob, Charlie.activeUserCountreturns 3 (it was 2).adminUsersis unchanged: only Alice.
If an action toggles users asynchronously (for example after a 1 second delay each), the count changes only when each delayed commit runs: it becomes 1 after the first completes and 2 after the second. A getter always shows the state at that moment; it never "runs ahead".
A getter with arguments returns a function: byDone: state => d => state.todos.filter(t => t.done === d), used as store.getters.byDone(false). Such getters are not cached.
Reactivity gotchas in Vue 2
Vue 2 cannot see two kinds of change. I tested both:
// not detected: no re-render
state.user.age = 9 // adding a NEW property to an object
state.list[0] = 100 // setting an array item by index
// detected
Vue.set(state.user, 'age', 9) // or this.$set(...)
state.list.splice(0, 1, 100)
state.user = { ...state.user, age: 9 } // replace the whole object
Declare every property in the initial state, even as null, and Vue tracks it from the start. Array methods like push, splice, pop are fine. (Vue 3 and Pinia use Proxies and do not have this gotcha.)
Idea 7: Modules, namespacing, strict mode
As the app grows, split the store into modules:
const cart = {
namespaced: true,
state: () => ({ items: [] }),
getters: { count: s => s.items.length },
mutations: { add(s, item) { s.items.push(item) } },
actions: { addLater({ commit }, item) { commit('add', item) } }
}
const store = new Vuex.Store({ modules: { cart } })
store.commit('cart/add', 'pen')
store.dispatch('cart/addLater', 'ink')
console.log(store.state.cart.items) // [ 'pen', 'ink' ]
console.log(store.getters['cart/count']) // 2
- Module state is nested:
store.state.cart.items. - Without
namespaced: true, mutations and actions of all modules share one global name space (names can clash). With it you add the prefix:'cart/add', andgetters['cart/count'](bracket access, because of the slash). - Module
stateshould be a function if the module may be reused. - Map helpers take a namespace first:
...mapState('cart', ['items']),...mapActions('cart', ['addLater']).
Strict mode: new Vuex.Store({ strict: true, ... }). If anything changes state outside a mutation (for example this.$store.state.count = 99), Vuex logs the error do not mutate vuex store state outside mutation handlers. Use it in development only; it has a performance cost, so turn it off in production (strict: process.env.NODE_ENV !== 'production').
Idea 8: API calls, the token, persistence, and when not to use Vuex
Call APIs from actions, then commit the result:
actions: {
async login({ commit }, creds) {
const res = await fetch('http://localhost:5000/login?include_auth_token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(creds)
})
if (!res.ok) throw new Error('Login failed') // component can catch it
const data = await res.json()
commit('setToken', data.response.user.authentication_token)
},
async fetchValue({ commit }) {
const value = await new Promise(r => setTimeout(() => r('API Value'), 500))
commit('setValue', value)
}
}
Only actions should hold fetch. Mutations only assign the already-fetched data to state. In the real paper, a component does await this.$store.dispatch('fetchValue') and after 500 ms the page shows API Value.
Auth token in the store and the page refresh problem
State lives in JavaScript memory. A refresh reloads the page and the memory is wiped, so the token vanishes and the user is logged out. Fix: keep a copy in web storage and load it when the store is created.
const store = new Vuex.Store({
state: {
token: sessionStorage.getItem('authToken') || null // initial value from storage
},
mutations: {
setToken(state, token) {
state.token = token
sessionStorage.setItem('authToken', token) // keep a copy
},
clearToken(state) {
state.token = null
sessionStorage.removeItem('authToken')
}
},
actions: {
login({ commit }, token) { commit('setToken', token) },
logout({ commit }) { commit('clearToken') }
}
})
After login and a refresh, the store starts again, reads sessionStorage, and the token is back in state.token. Storage facts the papers test:
| Storage | Lifetime | Shared across tabs | Sent to server automatically |
|---|---|---|---|
| Vuex state / JS variable | until refresh | no | no |
sessionStorage | until that tab closes (survives refresh) | no | no |
localStorage | until deleted (survives browser restart) | yes | no |
| cookie | until expiry; session cookie until browser closes | yes | yes, on every request |
Both web storages hold strings as key-value pairs: store objects with JSON.stringify and read with JSON.parse. Tradeoffs of storing tokens are in MAD2 week 8.
- Persisting only in the store does not survive refresh; the initial
statemust read from storage. localStorageis not cleared on refresh, and not "synchronised across devices".sessionStorageis not shared across tabs.- In a Vuex store, the mutation (not the component) is the right place to write storage, so the two copies never drift apart.
When NOT to use Vuex
- Small app or a page with one or two components: props and events are enough.
- Data used by one component (form fields, open/closed menus): local
data(). - Data that is just a copy of the server and can be re-fetched: fetch it where needed; a cache layer is simpler.
- Using Vuex is not always hard when apps grow; the opposite: it exists to keep large apps manageable. A component can still have its own local state when Vuex is used.
In newer Vue (Pinia). Vue 3 recommends Pinia, the successor of Vuex. It drops mutations (you change state in actions, or directly), has no modules (each store is its own file), and works with TypeScript and Proxies. The ideas you learn here (single source of truth, getters, actions) carry over. This course exam uses Vue 2 + Vuex 3, so write Vuex in answers.
Your project almost certainly needs a token and user role in the store. Examiner questions: "What happens on refresh?" (show the sessionStorage or localStorage restore), "Where is the API call made?" (an action), "Why a mutation to set the token?" (devtools history, single writer). Keep everything else local unless two far-apart components need it. Also be ready to say that hiding a button by role in Vue is a convenience: the Flask API must check the role too.
(a) A mutation may contain a fetch call. (b) this.$store.dispatch('x') returns a Promise. (c) Getters are not reactive. (d) Only the direct children of the root component have $store. (e) commit('m', a, b) gives the mutation both a and b.
Need a hint?
Think about sync/async and how many payload values exist.
Show the full solution
(a) False: mutations are synchronous; fetch belongs in actions. (b) True. (c) False: getters are reactive and cached. (d) False: all descendants. (e) False: only the first extra argument is the payload; send an object.
const store = new Vuex.Store({
state: { n: 1 },
mutations: { add(state, k) { state.n += k } },
actions: { addTwice({ commit }, k) { commit('add', k); commit('add', k) } }
})
store.dispatch('addTwice', 3)
console.log(store.state.n)Need a hint?
Is anything asynchronous here?
Show the full solution
7. The action is not async and has no await, so both commits run immediately and n becomes 1 + 3 + 3 = 7 before the next line.
The login action calls commit('setToken', token) which sets state.token = token. Users report being logged out on refresh. Fix it.
Need a hint?
Where does state.token get its starting value?
Show the full solution
Memory is lost on refresh. Initialise from storage: token: localStorage.getItem('authToken') || null, and in the mutation also call localStorage.setItem('authToken', token). Remove it on logout.
Exam radar
Counted by keywords in the graded papers, about 48 distinct Vuex-related questions were found, with roughly 59 of 608 Quiz 2 questions and 82 of 1471 End Term questions matching the week's keywords (approximate, the keyword match also catches neighbours). Vuex is a favourite in Quiz 2 and the End Term. The real questions test:
- the "fill code1 and code2" store question (awardees, students, total cost):
context.commitin an action, one payload object,state.prefix inside the mutation (seen in 2022, 2023, 2024 papers); - which statements about Vuex are true: single source of truth, common solution for parent-child and sibling data, multiple views on the same state, actions can be async; false: "
this.$vuexstore", "a component cannot have local state", "getters are not reactive"; - mutation versus action: mutations directly modify state and are synchronous; only actions make API calls;
- sequence: dispatch, commit, state change;
- predict the output of an action with
setTimeout/awaitand a console log, getter counts after a mutation, devtools history of commit vs dispatch vs direct push; - mapState with the spread syntax; and token persistence via
sessionStorageafter refresh.
this.$store.commitinside an action, orcontext.commitinside a component: wrong place.commit('x', a, b): onlyaarrives.- Writing
awardees.push(...)orcapitalwithoutstate./company.inside a mutation. - Declaring a mutation async or putting
fetchin it. mapState([...])without the spread when you also have other computed properties.- Thinking the first console line after
dispatchshows the final value.
Quick quiz
In Vuex, what is the purpose of mutations?
- To commit asynchronous operations
- To directly (and synchronously) change the state
- To get the state
- To define computed properties
Which statements about Vuex are correct? (select all that apply)
- Vuex is preferred when multiple views depend on the same state.
- Vuex provides actions for asynchronous operations.
- Vuex getters are not reactive.
- Only direct children of the root can access the store.
$store.What is the correct sequence to update state after an async operation?
- State, dispatch, commit, state change
- Dispatch an action, commit a mutation, state change
- Commit a mutation, dispatch an action, state change
- State change, commit, dispatch
Which snippet is a correct mutation?
ADD(state, p) { state.cart.push(p) }ADD(state, p) { setTimeout(() => state.cart.push(p), 500) }ADD(state, p) { fetch('/api').then(() => state.cart.push(p)) }ADD(state, p) { Promise.resolve().then(() => state.cart.push(p)) }
Inside a Vuex action, which line correctly updates the store with a min value and a token?
this.$store.commit('update_final', 40000, token)context.commit('update_final', 40000, token)context.commit('update_final', { min: 40000, token: token })this.$store.update_final({ min: 40000 })
context, and a payload is one value, so wrap several values in an object.After login the token is saved in state.token and also in sessionStorage, and state.token starts as sessionStorage.getItem('authToken') || null. The user refreshes. What happens?
- Both are lost
- Vuex keeps it, sessionStorage resets
- The Vuex state loads the token from sessionStorage again
- sessionStorage is cleared on refresh
Viva: say it out loud
Why did you use Vuex in your project?
Several components, such as the navbar, the dashboard and the router guards, need the same data: the logged-in user, role and token. Passing it through props would be prop drilling, and an event bus has no single record of changes. Vuex gives one reactive source of truth and every change is a named mutation I can inspect in devtools.
Difference between a mutation and an action?
A mutation is a synchronous function that changes state directly, and it is the only way to do so. An action can be asynchronous, can call the API with fetch, and changes state only by committing mutations. I call mutations with commit and actions with dispatch.
Why must mutations be synchronous?
Devtools records the state before and after each mutation to show history and allow time travel. If a mutation changed state later in a callback, the tool could not link the change to the mutation. So async work goes into actions.
What happens to the store when I refresh the page?
The store lives in memory, so it is rebuilt empty. I keep the token in localStorage, write it in the setToken mutation, and initialise state.token from localStorage at startup. That way the user stays logged in.
Where do you call your API and why?
In actions. An action can wait for the response with await, handle errors, and then commit a mutation with the data. The component only dispatches the action and reads the result through computed properties or mapState.
What are getters and what are modules?
A getter is a cached, reactive computed value derived from state, such as the count of active users. A module is a separate slice of the store with its own state, mutations and actions; with namespaced: true its names get a prefix like cart/add, which avoids clashes in big apps.
When would you not use Vuex?
For small apps, or for data that only one component needs, such as a form field or a dropdown flag. Props, events and local data() are simpler there. In Vue 3 the recommended store is Pinia.
Recap
- Vuex = one reactive store shared by all components; data flows one way: dispatch, commit, mutate, render.
- State is read (via computed,
mapState); getters are cached derived values; mutations change state synchronously; actions do async work and commit. commit('name', payload)anddispatch('name', payload): one payload. Components usethis.$store, actions usecontext.dispatchreturns a Promise; code before the firstawaitin an action runs immediately.- Modules split the store;
namespaced: trueadds themodule/prefix. Strict mode is for development. - The token in the store is lost on refresh; restore it from
localStorageorsessionStoragein the initial state. - Vue 2 cannot detect new properties or index assignment: use
Vue.setor replace the object. - Newer Vue uses Pinia; do not use Vuex for tiny or purely local data.
new Vuex.Store({ state, getters, mutations, actions, modules, strict })- mutation
(state, payload); getter(state, getters); action(context, payload)with{ commit, dispatch, state, getters } - component:
this.$store.state.x,this.$store.getters.x,this.$store.commit('m', p),this.$store.dispatch('a', p) - helpers:
...mapState(['x']),...mapGetters(['g'])incomputed;...mapMutations(['m']),...mapActions(['a'])inmethods - namespaced:
commit('cart/add', x),getters['cart/count'],mapState('cart', ['items']) - persistence:
sessionStorage.setItem(k, v),getItem(k),removeItem(k); same API onlocalStorage; strings only - Reactivity fix:
Vue.set(obj, 'key', val),arr.splice(i, 1, val)
Practise with real exam questions
These come straight from past papers (with the real figures and the official answer key). Open 60 real questions on this week All Quiz 2 papers