Single Page Apps with Vue.js and Flask: State Management with Vuex

Single Page Apps with Vue.js and Flask: State Management with Vuex

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

  1. Seup and Getting to Know VueJS
  2. Navigating Vue Router
  3. State Management with Vuex (you are here)
  4. RESTful API with Flask
  5. AJAX Integration with REST API
  6. JWT Authentication
  7. 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:

Free eBook: Git Essentials

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:

Resources

Want to learn more about Vue.js and building front-end web apps? Try checking out some of the following resources for a deeper dive in to this front-end framework:

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.

Last Updated: July 19th, 2023
Was this article helpful?

Improve your dev skills!

Get tutorials, guides, and dev jobs in your inbox.

No spam ever. Unsubscribe at any time. Read our Privacy Policy.

Adam McQuistanAuthor

I am both passionate and inquisitive about all things software. My background is mostly in Python, Java, and JavaScript in the areas of science but, have also worked on large ecommerce and ERP apps.

Project

React State Management with Redux and Redux-Toolkit

# javascript# React

Coordinating state and keeping components in sync can be tricky. If components rely on the same data but do not communicate with each other when...

David Landup
Uchechukwu Azubuko
Details

Getting Started with AWS in Node.js

Build the foundation you'll need to provision, deploy, and run Node.js applications in the AWS cloud. Learn Lambda, EC2, S3, SQS, and more!

© 2013-2024 Stack Abuse. All rights reserved.

AboutDisclosurePrivacyTerms