State Management with Vuex
Thanks for joining me for the third post on using Vue.js
and Flask for full-stack web development. The major topic in this post will be on using Vuex
to manage state in our app. To introduce Vuex
, I will demonstrate how to refactor the Home and Survey components from the previous post to utilize Vuex
, and I also build out the ability to add new surveys utilizing the Vuex
pattern.
The code for this post is in a repo on my GitHub account under the branch ThirdPost.
Series Content
- Seup and Getting to Know VueJS
- Navigating Vue Router
- State Management with Vuex (you are here)
- RESTful API with Flask
- AJAX Integration with REST API
- JWT Authentication
- Deployment to a Virtual Private Server
Introducing Vuex
Vuex is a centralized state management library officially supported by the core Vue.js
development team. Vuex
provides a flux-like, unidirectional data flow, pattern that is proven to be very powerful in supporting moderate to large Vue.js
applications.
There are other implementations of flux-like state management patterns and libraries, but Vuex
has been designed to specifically work with Vue.js
's fast and simple reactivity system. This is accomplished through a well-designed API that provides a single source of truth for an application's data as a singleton object. In addition to the single source of truth principle, Vuex
also provides explicit and track-able methods for asynchronous operations (actions), convenient reusable accessors (getters), and data altering capabilities (mutations).
To use Vuex
, I will first need to install it in the same directory that contains the package.json
file like so:
$ npm install --save vuex
Next I add a new directory within the project's src/
directory called store
and add an index.js
file. This results in the survey-spa project structure that now looks like this (ignoring the node_modules, build, and config directories):
├── index.html
├── package-lock.json
├── package.json
├── src
│ ├── App.vue
│ ├── api
│ │ └── index.js
│ ├── assets
│ │ └── logo.png
│ ├── components
│ │ ├── Header.vue
│ │ ├── Home.vue
│ │ └── Survey.vue
│ ├── main.js
│ ├── router
│ │ └── index.js
│ └── store
│ └── index.js
└── static
└── .gitkeep
Inside the store/index.js
file I begin by adding the necessary imports for Vue
and Vuex
objects then attach Vuex
to Vue
using Vue.use(Vuex)
similar to what was done with vue-router
. After this I define four stubbed out JavaScript objects: state
, actions
, mutations
, and getters
.
At the end of the file I define a final object, which is an instance of the Vuex.Store({})
object, that pulls all the other stub objects together, and then it is exported.
// src/store/index.js
import Vue from 'vue'
import Vuex from 'vuex'
Vue.use(Vuex)
const state = {
// single source of data
}
const actions = {
// asynchronous operations
}
const mutations = {
// isolated data mutations
}
const getters = {
// reusable data accessors
}
const store = new Vuex.Store({
state,
actions,
mutations,
getters
})
export default store
Ok, give me a few moments to explain the meaning of the state
, actions
, mutations
, and getters
objects.
The state
object will serve as the single source of truth where all the important application-level data is contained within the store. This state
object will contain survey data that can be accessed and watched for changes by any components interested in them such as the Home component.
The actions
object is where I will define what are known as action methods. Action methods are referred to as being "dispatched" and they're used to handle asynchronous operations such as AJAX calls to an external service or API.
The mutations
object provides methods which are referred to as being "committed" and serve as the one and only way to change the state of the data in the state
object. When a mutation is committed any components that are referencing the now reactive data in the state
object are updated with the new values, causing the UI to update and re-render its elements.
The getters
object contains methods also, but in this case they serve to access the state
data utilizing some logic to return information. Getters are useful for reducing code duplication and promote reusability across many components.
The last necessary step to activate the store takes place back in src/main.js
where I import the store
module just created. Then down in the options object where the top level Vue instance is instantiated I add the imported store
as a property. This should look as follows:
// src/main.js
import Vue from 'vue'
import App from './App'
import router from './router'
import store from './store'
Vue.config.productionTip = false
new Vue({
el: '#app',
router,
store,
components: { App },
template: '<App/>'
})
Migrating the Home Component to Vuex
I would like to start off utilizing Vuex
in the Survey application by migrating the way surveys are loaded into the Home component to use the Vuex
pattern. To begin I define and initialize an empty surveys array in the state
object within store/index.js
. This will be the location where all application level survey data will reside once pulled in by an AJAX request.
const state = {
// single source of data
surveys: []
}
Now that the surveys have a place to reside I need to create an action method, loadSurveys(...)
, that can be dispatched from the Home component (or any other component requiring survey data) to handle the asynchronous request to the mock AJAX function fetchSurveys()
. To use fetchSurveys()
I first need to import it from the api
module then define the loadSurveys(...)
action method to handle making the request.
Actions often work in tandem with mutations in a pattern of performing asynchronous AJAX requests for data to a server followed by explicitly updating the store's state
object with the fetched data. Once the mutation is committed then the parts of the application using the surveys will recognize there are updated surveys via Vue's reactivity system. Here the mutation I am defining is called setSurveys(...)
.
import Vue from 'vue'
import Vuex from 'vuex'
// imports of AJAX functions go here
import { fetchSurveys } from '@/api'
Vue.use(Vuex)
const state = {
// single source of data
surveys: []
}
const actions = {
// asynchronous operations
loadSurveys(context) {
return fetchSurveys()
.then((response) => context.commit('setSurveys', { surveys: response }))
}
}
const mutations = {
// isolated data mutations
setSurveys(state, payload) {
state.surveys = payload.surveys
}
}
With the store now possessing the ability to fetch surveys, I can update the Home component and utilize the store to feed it survey data. Back in src/components/Home.vue
, I remove the import of the fetchSurveys
function:
import { fetchSurveys } from '@/api'
and replace it with an import to the Vuex
helper function called mapState
.
import { mapState } from 'vuex'
I will use mapState
to map the surveys
array that resides in the state
object to a computed property also called surveys
. mapState
is simply a function that maintains a reference to a specific property of the state
object (state.surveys
in this case), and if that property is mutated a component using mapState
will react to that change and refresh any UI that is tied to that data.
In the Home component I have added the new surveys
computed property. Additionally, in the beforeMount
method I trigger the dispatch of the loadSurveys
store action. Since there is now a computed property called surveys
I should remove the existing surveys
property from the data portion of the component's Vue object. In fact, since that was the only data property I should also remove the whole data property to keep things tidy, as shown below.
<script>
import { mapState } from 'vuex'
export default {
computed: mapState({
surveys: state => state.surveys
}),
beforeMount() {
this.$store.dispatch('loadSurveys')
}
}
</script>
Note that I am able to access the store and dispatch the action method with the syntax this.$store.dispatch(...)
. This should look similar to the way I accessed the route in the previous article using this.$route
. This is because both the vue-router
and the Vuex
library inject these objects into the Vue instance as convenience properties. I could have also accessed the store's state.surveys
array from within the component using this.$store.state.surveys
instead of using mapState
, and I can also commit mutations using this.$store.commit
.
At this point I should be able to save my project and observe the same functionality in my browser by requesting the URL localhost:8080
as seen before.
Migrating the Survey Component
The next task is to migrate the Survey component to utilize Vuex
's store to fetch the specific survey to participate in taking. The general flow for the Survey component will be to access the :id
prop of the route and then utilize a Vuex
action method to fetch the survey by that id
. Instead of directly calling the mock AJAX function fetchSurvey
as done previously, I want to delegate that to another store action method which can then save (ie, commit a mutation) the fetched survey to a state
property I will name currentSurvey
.
Starting in the store/index.js module I change this line:
import { fetchSurveys } from '@/api'
to
import { fetchSurveys, fetchSurvey } from '@/api'
This gives me access to fetchSurvey
within the store module. I use fetchSurvey
in a new action method named loadSurvey
which then commits a mutation in another new method within the mutations
object called setCurrentSurvey
.
// src/store/index.js
const actions = {
// asynchronous operations
loadSurveys(context) {
// omitted for brevity
},
loadSurvey(context, { id }) {
return fetchSurvey(id)
.then((response) => context.commit('setSurvey'. { survey: response }))
}
}
Above is the implementation of the fetchSurvey
action method similar to the previous fetchSurveys
, except it is given an additional object parameter with an id property for the survey to be fetched. To simplify access to the id I use ES2015 object destructuring. When the action is called from a component the syntax will look like this: this.$store.dispatch('loadSurvey', { id: 1 })
.
Next I add the currentSurvey
property to the state
object. Finally, I define a mutation called setSurvey
in the mutations
object, which adds a choice
field to each question, to hold the survey taker's selected choice, and set the value of currentSurvey
.
const state = {
// single source of data
surveys: [],
currentSurvey: {}
}
const actions = { // omitted for brevity }
const mutations = {
// isolated data mutations
setSurveys(state, payload) {
state.surveys = payload.surveys
},
setSurvey(state, payload) {
const nQuestions = payload.survey.questions.length
for (let i = 0; i < nQuestions; i++) {
payload.survey.questions[i].choice = null
}
state.currentSurvey = payload.survey
}
}
Over in the Survey.vue
component file, I update the beforeMount
method to dispatch the loadSurvey
action and map state.currentSurvey
to a computed property called survey
. Afterwards I can remove the existing survey
data property.
<script>
import { saveSurveyResponse } from '@/api'
export default {
data() {
return {
currentQuestion: 0
}
},
beforeMount() {
this.$store.dispatch('loadSurvey', { id: parseInt(this.$route.params.id) })
},
methods: {
// omitted for brevity
},
computed: {
surveyComplete() {
// omitted for brevity
},
survey() {
return this.$store.state.currentSurvey
}
}
}
</script>
Saving the project files and refreshing the browser to request the URL localhost:8080/#/surveys/2
gives me the same UI again as shown below.
However, there is a bit of an issue yet. In the template code that displays each question's choices I am using v-model="question.choice"
to track changes when a user selects a choice.
<div v-for="choice in question.choices" v-bind:key="choice.id">
<label class="radio">
<input type="radio" v-model="question.choice" :value="choice.id">
{{ choice.text }}
</label>
</div>
This results in changes to the question.choice
value which are referenced within the store's state.currentQuestion
property. This is an example of incorrectly altering store data outside of a mutation. The vuex documentation advises that any changes to the store's state data be done exclusively using mutations. You might be asking, how then can I use v-model
in combination with an input element that is driven by data sourced from a Vuex
store?
The answer to this is to use a slightly more advanced version of a computed property that contains a defined pair of get
and set
methods within it. This provides v-model
a mechanism for utilizing 2-way data binding between the UI and the component's Vue object. In this way the computed property is explicitly in control of the interactions with the store's data. In the template code I need to replace v-model="question.choice"
with the new computed property like this v-model="selectedChoice"
. Below is the implementation of the computed property selectedChoice
.
computed: {
surveyComplete() {
// omitted for brevity
},
survey() {
return this.$store.state.currentSurvey
},
selectedChoice: {
get() {
const question = this.survey.questions[this.currentQuestion]
return question.choice
},
set(value) {
const question = this.survey.questions[this.currentQuestion]
this.$store.commit('setChoice', { questionId: question.id, choice: value })
}
}
}
Note that in this implementation selectedChoice
is actually an object property instead of a function like the others. The get
function works in conjunction with the currentQuestion
data property to return the choice
value of the question currently being viewed. The set(value)
portion receives the new value that is fed from v-model
's 2-way data binding and commits a store mutation called setChoice
. The setChoice
mutation is passed an object payload containing the id
of the question to be updated along with the new value
.
I add the setChoice
mutation to the store module as so:
Check out our hands-on, practical guide to learning Git, with best-practices, industry-accepted standards, and included cheat sheet. Stop Googling Git commands and actually learn it!
const mutations = {
setSurveys(state, payload) {
state.surveys = payload.surveys
},
setSurvey(state, payload) {
// omitted for brevity
},
setChoice(state, payload) {
const { questionId, choice } = payload
const nQuestions = state.currentSurvey.questions.length
for (let i = 0; i < nQuestions; i++) {
if (state.currentSurvey.questions[i].id === questionId) {
state.currentSurvey.questions[i].choice = choice
break
}
}
}
}
The last thing to migrate in the Survey component is the saving of the survey response choices. To begin, in Survey.vue
, I need to remove the import of the saveSurveyResponse
AJAX function
import { saveSurveyResponse } from '@/api'
and add it as an import in src/store/index.js module like so:
import { fetchSurveys, fetchSurvey, saveSurveyResponse } from '@/api'
Now down in the actions
methods of the store/index.js module I need to add a new method called addSurveyResponse
, which will call the saveSurveyResponse
AJAX function and eventually persist it to the server.
const actions = {
loadSurveys(context) {
// omitted for brevity
},
loadSurvey(context, { id }) {
// omitted for brevity
},
addSurveyResponse(context) {
return saveSurveyResponse(context.state.currentSurvey)
}
}
Back in the Survey.vue
component file, I need to update the handleSubmit
method to dispatch this action method instead of directly calling saveSurveyResponse
like so:
methods: {
goToNextQuestion() {
// omitted for brevity
},
goToPreviousQuestion() {
// omitted for brevity
},
handleSubmit() {
this.$store.dispatch('addSurveyResponse')
.then(() => this.$router.push('/'))
}
}
Adding the Ability to Create New Surveys
The remainder of this post will be dedicated to building out the functionality to create a new survey complete with its name, questions, and choices for each question.
To begin I will need to add a component file called NewSurvey.vue
inside of the components directory. Next I will want to import it and add a new route in router/index.js
module like so:
// other import omitted for brevity
import NewSurvey from '@/components/NewSurvey'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/',
name: 'Home',
component: Home
}, {
path: '/surveys/:id',
name: 'Survey',
component: Survey
}, {
path: '/surveys',
name: 'NewSurvey',
component: NewSurvey
}
]
})
Inside the Header.vue
file, I need to add a nav link to be able to navigate to the create view.
<template>
<nav class="navbar is-light" role="navigation" aria-label="main navigation">
<div class="navbar-menu">
<div class="navbar-start">
<router-link to="/" class="navbar-item">
Home
</router-link>
<router-link to="/surveys" class="navbar-item">
Create Survey
</router-link>
</div>
</div>
</nav>
</template>
Now in the NewSurvey.vue
component, I will scaffold out the basic structure of the create survey UI.
<template>
<div>
<section class="hero is-primary">
<div class="hero-body">
<div class="container has-text-centered">
<h2 class="title">{{ name }}</h2>
</div>
</div>
</section>
<section class="section">
<div class="container">
<div class="tabs is-centered is-fullwidth is-large">
<ul>
<li :class="{'is-active': step == 'name'}" @click="step = 'name'">
<a>Name</a>
</li>
<li :class="{'is-active': step == 'questions'}" @click="step = 'questions'">
<a>Questions</a>
</li>
<li :class="{'is-active': step == 'review'}" @click="step = 'review'">
<a>Review</a>
</li>
</ul>
</div>
<div class="columns">
<div class="column is-half is-offset-one-quarter">
<div class="name" v-show="step === 'name'">
<h2 class='is-large'>Add name</h2>
</div>
<div class="questions" v-show="step === 'questions'">
<h2>Add Questions</h2>
</div>
<div class="review" v-show="step === 'review'">
<h2>Review and Submit</h2>
</div>
</div>
</div>
</div>
</section>
</div>
</template>
<script>
export default {
data() {
return {
step: 'name'
}
}
}
</script>
<style></style>
As you can see in the screenshot above there are three tabs which will trigger the displaying of the UI components for adding the name, questions, and review before saving.
The functionality that drives the interactivity of this page is dictated based on the value of a step
data property that determines which tab should be active. step
defaults to the "name" tab, but is updated when a user clicks on one of the other tabs. Not only does the value of step
determine which tab should have the is-active
class, but it also drives the showing and hiding of divs
that provide UI for adding name, question, and review before submit.
I begin with the name UI's div
which simply contains a text input tied to a name
data property via v-model
, like so:
template portion
<div class="name" v-show="step === 'name'">
<div class="field">
<label class="label" for="name">Survey name:</label>
<div class="control">
<input type="text" class="input is-large" id="name" v-model="name">
</div>
</div>
</div>
script portion
data() {
return {
step: 'name',
name: ''
}
}
The questions and responses UI is going to be a bit more involved. To keep the NewSurvey
component more organized and reduce complexity I will add a NewQuestion.vue
file component to handle the UI and behavior necessary to add new questions along with a variable number of responses.
I should also note that for the NewSurvey and NewQuestion components I will be utilizing component-level state to isolate the store from the intermediary new survey data until a user submits the new survey. Once submitted I will engage Vuex
's store and associated pattern of dispatching an action to POST the new survey to the server then redirect to the Home component. The Home component can then fetch all surveys including the new one.
In the NewQuestion.vue
file, I now have the following code:
<template>
<div>
<div class="field">
<label class="label is-large">Question</label>
<div class="control">
<input type="text" class="input is-large" v-model="question">
</div>
</div>
<div class="field">
<div class="control">
<a class="button is-large is-info" @click="addChoice">
<span class="icon is-small">
<i class="fa fa-plus-square-o fa-align-left" aria-hidden="true"></i>
</span>
<span>Add choice</span>
</a>
<a class="button is-large is-primary @click="saveQuestion">
<span class="icon is-small">
<i class="fa fa-check"></i>
</span>
<span>Save</span>
</a>
</div>
</div>
<h2 class="label is-large" v-show="choices.length > 0">Question Choices</h2>
<div class="field has-addons" v-for="(choice, idx) in choices" v-bind:key="idx">
<div class="control choice">
<input type="text" class="input is-large" v-model="choices[idx]">
</div>
<div class="control">
<a class="button is-large">
<span class="icon is-small" @click.stop="removeChoice(choice)">
<i class="fa fa-times" aria-hidden="true"></i>
</span>
</a>
</div>
</div>
</div>
</template>
<script>
export default {
data() {
return {
question: '',
choices: []
}
},
methods: {
removeChoice(choice) {
const idx = this.choices.findIndex(c => c === choice)
this.choices.splice(idx, 1)
},
saveQuestion() {
this.$emit('questionComplete', {
question: this.question,
choices: this.choices.filter(c => !!c)
})
this.question = ''
this.choices = []
},
addChoice() {
this.choices.push('')
}
}
}
</script>
<style>
.choice {
width: 90%;
}
</style>
Most of the features have already been discussed so I will only briefly review them. To begin, I have a question
data property which is bound to a text input via v-model="question"
providing 2-way data binding between the data property question
and the input element of the UI.
Below the question text input are two buttons. One of the buttons is for adding a choice and it contains an event listener @click="addChoice"
which pushes an empty string onto the choices
array. The choices
array is used to drive the display of choice text inputs which are each tied to their respective element of the choices
array via v-model="choices[idx]"
. Each choice text input is paired with a button that allows the user to remove it due to the presence of the click event listener @click="removeChoice(choice)"
.
The last piece of UI in the NewQuestion component to discuss is the save button. When a user has added their question and the desired number of choices they can click this to save the question. This is accomplished via the click listener @click="saveQuestion"
.
However, inside of the saveQuestion
method I have introduced a new topic. Notice that I am making use of another method attached to the component's Vue instance. This is the this.$emit(...)
event emitter method. In calling this I am broadcasting to the parent component, NewSurvey, the event called questionComplete
and passing along with it a payload object with the question
and choices
.
Back in the NewSurvey.vue
file, I will want to import this NewQuestion
component and register it to the component's Vue instance like this:
<script>
import NewQuestion from '@/components/NewQuestion'
export default {
components: { NewQuestion },
data() {
return {
step: 'name',
name: ''
}
}
}
</script>
Then I can include it in the template as a component element like so:
<div class="questions" v-show="step === 'questions'">
<new-question v-on:questionComplete="appendQuestion"/>
</div>
Notice that I have used the v-on
directive to listen for the questionComplete
event to be emitted from the NewQuestion component and registered a callback of appendQuestion
. This is the same concept as what we have seen with the @click="someCallbackFunction"
event listener, but this time it's for a custom event. By the way, I could have used the shorter @questionComplete="appendQuestion"
syntax but I thought I would throw in some variety, and it's also more explicit this way.
The next logical thing would be to add the appendQuestion
method to the NewSurvey
component along with a questions
data property to maintain the collection of questions and responses generated in the NewQuestion
component and emitted back to NewSurvey
.
export default {
components: { NewQuestion },
data() {
return {
step: 'name',
name: '',
question: []
}
},
methods: {
appendQuestion(newQuestion) {
this.questions.push(newQuestion)
}
}
}
I can now save and refresh by browser to the URL localhost:8080/#/surveys
then click on the Questions tab, add a question's text and a few choices as shown below.
The final tab to complete is the Review tab. This page will list the questions and choices as well as offer the user the ability to delete them. If the user is satisfied then they can submit the survey and the application will redirect back to the Home component.
The template portion of the code for the review UI is as follows:
<div class="review" v-show="step === 'review'">
<ul>
<li class="question" v-for="(question, qIdx) in questions" :key="`question-${qIdx}`">
<div class="title">
{{ question.question }}
<span class="icon is-medium is-pulled-right delete-question"
@click.stop="removeQuestion(question)">
<i class="fa fa-times" aria-hidden="true"></i>
</span>
</div>
<ul>
<li v-for="(choice , cIdx) in question.choices" :key="`choice-${cIdx}`">
{{ cIdx + 1 }}. {{ choice }}
</li>
</ul>
</li>
</ul>
<div class="control">
<a class="button is-large is-primary" @click="submitSurvey">Submit</a>
</div>
</div>
The script portion now only needs to be updated by adding the removeQuestion
and submitSurvey
methods to handle their respective click event listeners.
methods: {
appendQuestion(newQuestion) {
this.questions.push(newQuestion)
},
removeQuestion(question) {
const idx = this.questions.findIndex(q => q.question === question.question)
this.questions.splice(idx, 1)
},
submitSurvey() {
this.$store.dispatch('submitNewSurvey', {
name: this.name,
questions: this.questions
}).then(() => this.$router.push('/'))
}
}
The removeQuestion(question)
method removes the question from the questions
array in the data property which reactively updates the list of questions composing the UI above. The submitSurvey
method dispatches a soon-to-be added action method submitNewSurvey
and passes it the new survey content and then uses the component's this.$router.push(...)
to redirect the application to the Home component.
Now the only thing to do is to create the submitNewSurvey
action method and corresponding mock AJAX function to fake POSTing to the server. In the store's actions
object I add the following.
const actions = {
// asynchronous operations
loadSurveys(context) {
return fetchSurveys()
.then((response) => context.commit('setSurveys', { surveys: response }))
},
loadSurvey(context, { id }) {
return fetchSurvey(id)
.then((response) => context.commit('setSurvey', { survey: response }))
},
addSurveyResponse(context) {
return saveSurveyResponse(context.state.currentSurvey)
},
submitNewSurvey(context, survey) {
return postNewSurvey(survey)
}
}
Finally, in the api/index.js
module, I add the postNewSurvey(survey)
AJAX function to mock a POST to a server.
export function postNewSurvey(survey) {
return new Promise((resolve, reject) => {
setTimeout(() => {
console.log('Saving survey ...', survey)
resolve()
}, 300)
})
}
I save all my project files and request the URL localhost:8080/#/surveys
. Then adding a name, some questions with choices, and pausing on the review tab I see the following UI:
Conclusion
During this post I have tried to cover what I feel are the most important aspects of a rather large topic, Vuex
. Vuex
is a very powerful addition to a Vue.js
project which affords the developer an intuitive pattern that improves the organization and robustness of moderate to large data-driven single page applications.
As always, thanks for reading and don't be shy about commenting or critiquing below.