Implementing a basic eCommerce cart with and without Vuex

A step by step guide revealing the complexities that arise without state management.

Overview

Resources and Setup

Vue.js Devtools Chrome Extension

Github Repo

Clone or download the startingPoint branch from the repo above. This branch contains some very basic functionality for adding and removing items from a users cart.

Mockapi.io

  1. Sign up for a free account here.
  2. After you’ve logged in, clone my project by visiting the following link https://mockapi.io/clone/5f21f583daa42f0016666158

Axios

  1. Copy the api endpoint from your mockapi project

2. Open src/axios/index.js and paste your endpoint url as the value for baseUrl.

import Axios from 'axios'const axios = Axios.create({
baseUrl: 'https://YOUR-ENDPOINT-ID.mockapi.io'
});
export default axios

Run the project

npm run serve

Then you should be able to visit http://localhost:8080/ to see the project running. You should see a list of products. Click on one to view the product page. The Add to Cart button on that page does exactly what you think. View your cart by hitting the Cart button in the header.

Great! You are setup and ready to begin.

Part 1: Without state management.

You probably noticed that clicking the Add to Cart button does not affect the count on the cart button in the header. Rest assured, the product has been added to the cart, you can check the Cart page to confirm. Let’s see what it takes to increment the count when a product is added to the cart.

CartButton.vue is the component which will need the updated data.

<template>
<router-link :to="{name: 'Cart'}" class="button is-primary">
Cart ({{count}})
</router-link>
</template>

<script>
export default {
props: {
count: Number
}
}
</script>

See how it receives the count as a prop and then prints the count in the template with curly bracket syntax {{count}}

You’ll find CartButton.vue included in App.vue, which binds the computed property cartLength to the count prop.

<cart-btn :count="cartLength" class="level-right"></cart-btn>

On the created lifecycle hook (still in App.vue) we make a call to get the cart using Axios. We then update this.cart to the response. The computed property cartLength returns the length of this.cart

<script>
import cartBtn from '@/components/CartButton.vue'
import axios from '@/axios'

export default {
data () {
return {
cart: []
}
},
computed: {
cartLength () {
return this.cart.length
}
},
components: {
'cart-btn': cartBtn
},
methods: {
async getCart() {
await axios.get(`cart`).then(response => {
this.cart = response.data
})
}
},
created() {
this.getCart()
}
}
</script>

In order to communicate the change in the cart we will need to pass an event up from the AddToCart.vue component all the way to the CartButton.vue component. Since child components can only emit events to their direct parent, each child component between AddToCart.vue and CartButton.vue will need to emit an event. See the diagram below.

To emit the first event, open up the AddToCart.vue component and update the addToCart() method. It makes sense to trigger the event after the post to the api succeeds.

addToCart () {
axios.post('cart', this.product).then(() => {
this.$emit('add-product-to-cart')
})

},

Now let’s hop to the the Product.vue component, which is where the AddToCart.vue component is included. Find where<add-to-cart> is included in the template and update it to the following.

<add-to-cart :product="product" v-on:add-product-to-cart="$emit('add-product-to-cart')"></add-to-cart>

Here’s how that breaks down:

v-on listens for events from the instance of that component, we’re listening for add-product-to-cart in this case. Since we have another component level to traverse before we have access to the CartButton.vue component, another another event needs to be emitted. We’ll give it the same name for simplicity’s sake $emit(‘add-product-to-cart’)

Navigate to a product page and open the events tab on the Vue devtools panel. When you click the Add to Cart button you should see 2 events.

See payload property in the event info? We can pass data with events. And since cartLength calculates the number of items in this.cart we’ll want to add the product to this.cart in App.vue. To accomplish this, let’s add product to the payload in the Product.vue component.

<add-to-cart :product="product" v-on:add-product-to-cart="$emit('add-product-to-cart', product)"></add-to-cart>

Now refresh the Product page and hit the Add to Cart button again. You should see the product returned as the payload in the event that was triggered by the Product.vue component.

Great, let’s jump into App.vue; we’ll throw a listener onto <router-view/> — it isn’t ideal to add listeners to the router-view but it’ll work for our basic example.

<router-view v-on:add-product-to-cart="onAddProductToCart"/>

This time we’re going to call the method onAddProductToCart which will add the product from the payload to this.cart— let’s add that method to App.vue.

onAddProductToCart (product) {
this.cart.unshift(product)
}

Awesome, now let’s see if our count updates.

Open a product page and hit the Add to Cart button again; you should see the count increment by 1.

You can also open the components tab in the Vue devtools extension to watch the cart array grow as you add products. Now we know the count is properly reflecting the size of the cart.

It works!

Take a look at the cart page. When you click Remove from Cart nothing appears to change. It’s a similar issue, which would require passing an event with the item’s id from the RemoveFromCart.vue component to the Cart.vue component. From there you would filter cart to exclude the item with an id equal to the id you passed.

Give it a try for yourself, or download the propsWithEvents branch from the repo, or just take a look at the commit diff.

Part 2: Setting up State Management with Vuex

State management is built around the concept of having one single source of truth across an application. When multiple components are dependent on the same set of data, it doesn’t make sense to manage data the way we did in the first part of this article.

There are a number of options when it comes to state management, we’ll be looking at it through the lens of Vuex. The concept remains the same no matter what technology you’re using.

First you’ll need to install Vuex. Open a terminal window and navigate to you project’s directory to run the following.

npm install vuex

Now, create a new directory named store in your project under src/

Then, create an index.js file in the store directory

To create a Vuex store, add the following to your new index.js file.

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);

export default new Vuex.Store({
state: {},
getters: {},
actions: {},
mutations: {}
})

The final step is to include this store on your Vue instance. Open up the main.js file, import store from ‘@/store’ and add store to the instance.

import Vue from 'vue'
import App from './App.vue'
import store from '@/store'
import router from './router'

Vue.config.productionTip = false

new Vue({
router,
store,
render: h => h(App)
}).$mount('#app')

Great! Your project is set up with Vuex now.

Vuex Store Anatomy Basic Overview

Getters: We use getters in our components when accessing the state. Method-style Access is where getters really show their power in my opinion.

Mutations: Mutations are used to update the data in our state. Like when adding a product to your cart.

Actions: We use actions for asynchronous operations, such as making an api call. Actions almost always commit mutations. When the api for products responds we’ll commit a mutation that sets state.products to the response.

Setting up state and getters

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);

export default new Vuex.Store({
state: {
cart: []
},
getters: {},
actions: {},
mutations: {}
})

We’ll access state.cart in our Cart.vue and CartButton.vue components using a getter. Let’s add the getter now.

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);

export default new Vuex.Store({
state: {
cart: []
},
getters: {
getCart: state => state.cart
},
actions: {},
mutations: {}
})

We can also use a getter to return the length of the cart, let’s add that too. We can utilize the getCart getter in this case. See docs on Property Style Access

import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex);

export default new Vuex.Store({
state: {
cart: []
},
getters: {
getCart: state => state.cart,
getCartLength: (state, getters) => {
return getters.getCart.length
}
},
actions: {},
mutations: {}
})

Now let’s make use of that getCartLength getter in our CartButton.vue component.

Instead of passing the count as a prop, we’ll use a computed property to make use of the getCartLength getter. Open CartButton.vue and update it to the following.

<template>
<router-link :to="{name: 'Cart'}" class="button is-primary">
Cart ({{count}})
</router-link>
</template>

<script>
import { mapGetters } from 'vuex'

export default {
computed: {
...mapGetters({
count: 'getCartLength'
})
}

}
</script>

We’re using the mapGetters helper, there are other ways to access your getters, you can read up on them here.

Notice that we map ‘getCartLength’ to count which is what our template is expecting.

Open your project back up in your browser. The cart button should indicate that no items have been added (even if you have added items). This is because we haven’t updated our state yet.

Setting up actions and mutations

async getCart() {
await axios.get(`cart`).then(response => {
this.cart = response.data
})
},

We’re going to do something very similar with our action, but instead of this.cart = response.data we’ll run a mutation that sets state.cart to response.data

Go ahead and remove everything but the following from the script tag in App.vue.

<script>
import cartBtn from '@/components/CartButton.vue'

export default {
components: {
'cart-btn': cartBtn
},
methods: {
onAddProductToCart (product) {
this.cart.unshift(product)
}
}
}
</script>

You should also remove the :count data binding from the cart-btn in the App.vue template. It should look like this <cart-btn class=”level-right”></cart-btn>

Open store/index.js and add the following action and mutation.

import Vue from 'vue'
import Vuex from 'vuex'
import axios from '@/axios'
Vue.use(Vuex);

export default new Vuex.Store({
state: {
cart: []
},
getters: {
getCart: state => state.cart,
getCartLength: state => state.cart.length
},
actions: {
async fetchCart() {
await axios.get(`cart`)
},

},
mutations: {
setCart: (state, cart) => (state.cart = cart)
}
})

That action will fetch the cart, and the mutation will set state.cart as whatever is passed in. Let’s tie them together by committing the setCart mutation after our get request succeeds.

export default new Vuex.Store({
state: {
cart: []
},
getters: {
getCart: state => state.cart,
getCartLength: state => state.cart.length
},
actions: {
async fetchCart({ commit }) {
await axios.get(`cart`).then(response => {
commit('setCart', response.data)
})

},
},
mutations: {
setCart: (state, cart) => (state.cart = cart)
}
})

Now we need to dispatch the fetchCart action. Let’s handle that in the created lifecycle hook in CartButton.vue. So once an instance of the CartButton.vue component is created, fetchCart will be called.

<template>
<router-link :to="{name: 'Cart'}" class="button is-primary">
Cart ({{count}})
</router-link>
</template>

<script>
import { mapGetters, mapActions } from 'vuex'

export default {
computed: {
...mapGetters({
count: 'getCartLength'
})
},
methods: {
...mapActions({
fetchCart: 'fetchCart'
})
},
created () {
this.fetchCart()
}

}
</script>

At this point, we’re fetching the cart, saving the response to state.cart, and printing {{count}} with the getCartLength getter.

You should see the cart count reflects the number of items in the cart when the CartButton.vue component is created, but the count does not increment when an item is added to the cart. Let’s handle that next.

Adding a product to state.cart

With Vuex we simply need to run a mutation once a product is successfully added to the cart.

Let’s create the addToCart action and the addProductToCart mutation.

export default new Vuex.Store({
state: {
cart: []
},
getters: {
getCart: state => state.cart,
getCartLength: state => state.cart.length
},
actions: {
async fetchCart({ commit }) {
await axios.get(`cart`).then(response => {
commit('setCart', response.data)
})
},
async addToCart ({ commit }, product) {
axios.post('cart', product).then(() => {
commit('addProductToCart', product)
})
},

},
mutations: {
setCart: (state, cart) => (state.cart = cart),
addProductToCart: (state, product) => (state.cart.unshift(product))
}
})

Now let’s remove the old addToCart() method from AddToCart.vue, and replace it with our action.

<template>
<button @click="addToCart(product)" class="button is-primary">Add to Cart</button>
</template>

<script>
import { mapActions } from 'vuex'

export default {
props: {
product: {
type: Object,
required: true
}
},
methods: {
...mapActions({
addToCart: 'addToCart'
})

a̶d̶d̶T̶o̶C̶a̶r̶t̶ ̶(̶)̶ ̶{̶
̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶a̶x̶i̶o̶s̶.̶p̶o̶s̶t̶(̶'̶c̶a̶r̶t̶'̶,̶ ̶t̶h̶i̶s̶.̶p̶r̶o̶d̶u̶c̶t̶)̶.̶t̶h̶e̶n̶(̶(̶)̶ ̶=̶>̶ ̶{̶
̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶t̶h̶i̶s̶.̶$̶e̶m̶i̶t̶(̶'̶a̶d̶d̶-̶p̶r̶o̶d̶u̶c̶t̶-̶t̶o̶-̶c̶a̶r̶t̶'̶)̶
̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶}̶)̶
̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶ ̶}̶
}
}
</script>

And we can remove the event listeners in Product.vue/App.vue

<add-to-cart :product="product" ̶v̶-̶o̶n̶:̶a̶d̶d̶-̶p̶r̶o̶d̶u̶c̶t̶-̶t̶o̶-̶c̶a̶r̶t̶=̶"̶$̶e̶m̶i̶t̶(̶'̶a̶d̶d̶-̶p̶r̶o̶d̶u̶c̶t̶-̶t̶o̶-̶c̶a̶r̶t̶'̶,̶ ̶p̶r̶o̶d̶u̶c̶t̶)̶"̶></add-to-cart><router-view  ̶v̶-̶o̶n̶:̶a̶d̶d̶-̶p̶r̶o̶d̶u̶c̶t̶-̶t̶o̶-̶c̶a̶r̶t̶=̶"̶o̶n̶A̶d̶d̶P̶r̶o̶d̶u̶c̶t̶T̶o̶C̶a̶r̶t̶"̶/>

Great! Now clicking the Add to Cart button will update the state.cart once the post is successful. We can see the count update on the CartButton.vue component as well.

Implementing our getters and actions on the Cart.vue component

We use our fetchCart action in the created lifecycle hook, and then our getCart getter as a computed property to print out the items in the template.

<script>
import { mapGetters, mapActions } from 'vuex'
import removeFromCart from '@/components/RemoveFromCart'

export default {
computed: {
...mapGetters({
cart: 'getCart'
})
},

components: {
'remove-from-cart': removeFromCart
},
methods: {
...mapActions({
fetchCart: 'fetchCart'
}),

onRemoveFromCart (productId) {
this.cart = this.cart.filter(product => product.id !== productId)
}
},
created () {
this.fetchCart()
}

}
</script>

We still need to utilize our Vuex when removing items from our cart. Here are the remaining tasks:

  1. Convert the removeFromCart method in the RemoveFromCart.vue component to an action.
  2. Convert onRemoveFromCart method in Cart.vue to a mutation
  3. Commit the onRemoveFromCart mutation when the removeFromCart action is successful.

See if you can do this yourself, or download the vuex branch from my repo, or view this commit to see how I did it.

Conclusion

Frontend Dev at Neutron Interactive

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store