Computing Application State in Vue 3

Introduction

We've all been there before- working on an application, and suddently we need to determine what state something is in. Maybe it's whether the form has been submitted already, or the class a certain element should have. You may be tempted to set that value to a variable, and move on. What's the harm, right?

It turns out that there are a number of reasons this could be a problem. Your state could be out of date due to a change from a different function. You could cause an 'impossible state', or a state in the UI that was never intended by the developer. And at the very least, your code is more imperative, meaning that you as the developer are having to write and maintain more lines of code. It's almost like manually juggling all the values in your application - what happens if you drop one?

Luckily, Vue provides a solution for this - the computed property. With the computed property (or Composition API method), we can perform calculations like we described above by declaring them and getting a readonly, reactive ref to use in our application. Vue is able to determine when a dependency in the computed property has changed, and recalculate its result. We can then use these calculated values as if they were another variable, and use them in our template and logic with ease.

Setting up our example

Let's start with a common example: You have been tasked with building a form that accepts a name, email, and comments. We want to track the number of characters a user has entered, and allow them to submit the form. Below is an example of this form:

<template>
  <form @submit.prevent="onSubmitFormHandler">
    <label for="name">
      Name
      <input id="name" type="text" v-model="formState.name" />
    </label>
    <label for="email">
      Email
      <input id="email" type="email" v-model="formState.email" />
    </label>
    <label for="comment">
      Comments
      <textarea
        id="comment"
        v-model="formState.comment"
        @input="updateCharacterCount"
      />
      {{ characterCount }} character(s)
    </label>
    <button>Submit Feedback</button>
  </form>
</template>

<script>
import { reactive, defineComponent, ref } from "vue";
import axios from "axios";

export default defineComponent({
  setup() {
    const formState = reactive({
      name: "",
      email: "",
      comment: "",
    });

    const characterCount = ref(0);

    const updateCharacterCount = (e) => {
      characterCount.value = e.target.value.length
    };

    const onSubmitFormHandler = () => {
      axios
        .post("http://localhost:3000/api/comment", formState)
        .then((res) => console.log(res))
        .catch((err) => console.log(err));
    };

    return {
      formState,
      characterCount,
      updateCharacterCount,
      onSubmitFormHandler,
    };
  },
});
</script>

The above form provides the basic functionality that we need. But there are a few things missing:

  • We're just logging out messages when the API comes back. We should report the error or success state to the user.
  • We probably shouldn't allow users to submit the form without filling out the fields.
  • We also shouldn't allow users to submit the form while their submission is being processed. We don't want to receive duplicate form entries.

With this in mind, let's rewrite our code. The template is the same, but we'll need to track the form's state - whether it has been submitted or had an error, and whether the user can click the submit button. Sounds simple enough, so let's add some booleans - submitting, hasError, hasSuccess. That should handle the state.

We don't want the user to have to click a "Validate" button, of course - that would be frustrating. So let's use the watch Composition API method, and calculate whether to show the submit button.

Below is our updated code:

<template>
  <form @submit.prevent="onSubmitFormHandler">
    <label for="name">
      Name
      <input id="name" type="text" v-model="formState.name" />
    </label>
    <label for="email">
      Email
      <input id="email" type="email" v-model="formState.email" />
    </label>
    <label for="comment">
      Comments
      <textarea id="comment" v-model="formState.comment" @input="updateCharacterCount" />
      {{ characterCount }} character(s)
    </label>
    <button :disabled="submitting || !formReadyToSubmit">
      Submit Feedback
    </button>
  </form>
  <div>
    <template v-if="hasError">Something went wrong!</template>
    <template v-if="hasSuccess">Submitting successfully!</template>
  </div>
</template>

<script>
import { reactive, ref, watch, defineComponent } from "vue";
import axios from "axios";

export default defineComponent({
  setup() {
    const formState = reactive({
      name: "",
      email: "",
      comment: "",
    });

    const characterCount = ref(0);

    const submitting = ref(false);
    const hasError = ref(false);
    const hasSuccess = ref(false);

    const formReadyToSubmit = ref(false);

    const updateCharacterCount = (e) => {
      characterCount.value = e.target.value.length
    };

    const validateHasInput = () => {
      formReadyToSubmit.value =
        formState.name.length > 0 &&
        formState.email.length > 0 &&
        formState.comment.length > 0;
    };

    watch(
      () => ({ ...formState }),
      () => {
        validateHasInput();
      },
      {
        deep: true,
      }
    );

    const onSubmitFormHandler = () => {
      hasError.value = false;
      hasSuccess.value = false;
      submitting.value = true;

      axios
        .post("http://localhost:3000/api/comment", formState)
        .then((res) => {
          console.log(res);

          hasSuccess.value = true;
          submitting.value = false;
        })
        .catch((err) => {
          console.log(err);

          hasError.value = true;
          submitting.value = false;
        });
    };

    return {
      formState,
      onSubmitFormHandler,
      submitting,
      hasError,
      hasSuccess,
      formReadyToSubmit,
      characterCount,
      updateCharacterCount
    };
  },
});
</script>

The way this is written works, but there are a number of issues:

  • In the onSubmitFormHandler, we are manually resetting each status to where it should be.
  • In addition, the fact that we are using multiple booleans to track our form's state means that we could end up in an impossible state, where both "submitting" and "hasError" are true. That could lead to unexpected, and unpredictable, user experiences.
  • The watcher does the job of recalculating whenever the formState is changed, but we're still having the manually track this value.
  • We're still manually handling the input event on the comments field to get the character count.

Most importantly, it's going to be much harder to refactor this going forward, because it's harder to determine what is going on. Is there a relationship between "hasError" and "hasSuccess"? What is calling "validateHasInput"?

Implementing Computed Property

Let's upgrade our application using the computed Composition method now. First, we'll replace the boolean states with a single state, and use computed properties to determine what state we are in. We can also use a computed property to get the character count, and remove that extra event on the comment textarea. Finally, we'll move the validateHasInput into its own computed property.

Here's the updated form:

<template>
  <form @submit.prevent="onSubmitFormHandler">
    <label for="name">
      Name
      <input id="name" type="text" v-model="formState.name" />
    </label>
    <label for="email">
      Email
      <input id="email" type="email" v-model="formState.email" />
    </label>
    <label for="comment">
      Comments
      <textarea id="comment" v-model="formState.comment" />
      {{ characterCount }} character(s)
    </label>
    <button :disabled="!formReadyToSubmit">Submit Feedback</button>
  </form>
  <div>
    <template v-if="hasError">Something went wrong!</template>
    <template v-if="hasSuccess">Submitting successfully!</template>
  </div>
</template>

<script>
import { reactive, ref, computed, defineComponent } from "vue";
import axios from "axios";

const Status = {
  IDLE: "IDLE",
  SUBMITTING: "SUBMITTING",
  SUCCESS: "SUCCESS",
  ERROR: "ERROR",
};

export default defineComponent({
  setup() {
    const formState = reactive({
      name: "",
      email: "",
      comment: "",
    });
    const characterCount = computed(() => formState.comment.length);

    const status = ref(Status.IDLE);

    const submitting = computed(() => status === Status.SUBMITTING);
    const hasError = computed(() => status === Status.ERROR);
    const hasSuccess = computed(() => status === Status.SUCCESS);

    const formReadyToSubmit = computed(
      () =>
        !submitting.value &&
        formState.name.length > 0 &&
        formState.email.length > 0 &&
        formState.comment.length > 0
    );

    const onSubmitFormHandler = () => {
      status.value = Status.SUBMITTING;

      axios
        .post("http://localhost:3000/api/comment", formState)
        .then((res) => {
          console.log(res);

          status.value = Status.SUCCESS;
        })
        .catch((err) => {
          console.log(err);

          status.value = Status.ERROR;
        });
    };

    return {
      formState,
      onSubmitFormHandler,
      submitting,
      hasError,
      hasSuccess,
      formReadyToSubmit,
      characterCount
    };
  },
});
</script>

We have now refactored to use the computed property. What does that give us?

  • The boolean values (submitting, hasError, hasSuccess) are no longer being set imperatively. They are being calculated based off of a status variable. If we were using something like Typescript, we could force the type of status to be a certain subset, but for now, we are using an object with a few states - including IDLE.
  • The character count is now being calculated off of the length of the comment string, rather than checking the length from the emitted event. This means that if we change the value of formState.comment programmatically, our character count is up to date without us having to change anything.
  • In the onSubmitFormHandler, we set which status our form is in as we go. We aren't having to set each status individually, which means we don't have the possibility of impossible states.

Using Getters in Vuex

Our application is in a much better state now, but there is still room to improve. One way we can better encapsulate our logic is by utilizing Vuex for managing the state of the form submission. Luckily, we can implement our computed logic in Vuex pretty easily with getters. In Vuex, getters fill the same role as computed properties in single-file components.

Let's move our API logic out of the component, and into Vuex:

import { createStore } from "vuex";
import axios from 'axios';

export const Status = {
  IDLE: "IDLE",
  SUBMITTING: "SUBMITTING",
  SUCCESS: "SUCCESS",
  ERROR: "ERROR",
};

export default createStore({
  state: {
    status: Status.IDLE
  },
  getters: {
    getStatus: state => state.status,
    submitting: state => state.status === Status.SUBMITTING,
    hasError: state => state.status === Status.ERROR,
    hasSuccess: state => state.status === Status.SUCCESS
  },
  mutations: {
    'SET_STATUS': (state, status) => state.status = status
  },
  actions: {
    submitForm: ({ commit }, payload) => {
      commit('SET_STATUS', Status.SUBMITTING);

      axios
        .post("http://localhost:3000/api/comment", payload)
        .then((res) => {
          console.log(res);

          commit('SET_STATUS', Status.SUCCESS);
        })
        .catch((err) => {
          console.log(err);

          commit('SET_STATUS', Status.ERROR);
        });
    }
  }
});

In Vuex, we create a store, which is then plugged into our app. This store contains our state (the submission status), a single mutation to handle updating the state, and an action for submitting the form. We then have our four computed properties, which match the three we had previously as well as getting the raw state.

By using Vuex, the state of our form submission is disconnected from the template. This can be useful when building out larger applcations, so that your components remain focused on the user experience, and the logic is handled within your global state management. Here's what our updated component's logic looks like:

import { reactive, computed, defineComponent } from "vue";
import { useStore } from "vuex";

export default defineComponent({
  setup() {
    const store = useStore();
    const formState = reactive({
      name: "",
      email: "",
      comment: "",
    });
    const characterCount = computed(() => formState.comment.length);

    const submitting = computed(() => store.getters.submitting);
    const hasError = computed(() => store.getters.hasError);
    const hasSuccess = computed(() => store.getters.hasSuccess);

    const formReadyToSubmit = computed(
      () =>
        !submitting.value &&
        formState.name.length > 0 &&
        formState.email.length > 0 &&
        formState.comment.length > 0
    );

    const onSubmitFormHandler = () => {
      store.dispatch("submitForm", formState);
    };

    return {
      formState,
      onSubmitFormHandler,
      submitting,
      hasError,
      hasSuccess,
      formReadyToSubmit,
      characterCount,
    };
  },
});

In our component, we are now importing useStore in order to access our Vuex store. The component then makes an API call via a dispatch, which calls our action. Neat!

Using Get/Set with Computed Properties

One more nice feature of computed properties is how they interact with ES5 getters and setters. If you aren't aware, with ES5 you can add a function to an object that is either a get() or a set(val). This function is not invoked like a normal function, but instead, whenever the value is read or assigned to. For example:

console.log(this.name) // Getter

this.name = "Tim"; // Setter

With computed properties, we can leverage this system to build additional functionality. Let's say that you want to add a reset function to the form. One potential way to do that could be like this:

const currentStatus = computed({
  get: () => store.getters.getStatus,
  set: (val) => store.commit("SET_STATUS", val),
});

const resetFormHandler = () => {
  formState.name = "";
  formState.email = "";
  formState.comment = "";

  currentStatus.value = Status.IDLE;
};

With the Composition API, if you pass an object in as the first parameter (rather than a function), you can use the get and set keys to make your own custom getter and setter. This way, rather than making the function call directly to store.commit, we can simply set the currentStatus to the status we want.

This ability to set to computed properties is especially useful when using v-model on a custom component. Below is an example where this can help to model a custom input.

<template>
  <input v-model="inputValue">
</template>

<script>
export default {
  emits: [ 'update:modelValue' ],
  props: {
    modelValue: {
      type: String,
      default: ''
    }
  },
  computed: {
    inputValue: {
      get() {
        return this.modelValue;
      },
      set(val) {
        this.$emit('update:modelValue', val);
      }
    }
  }
}
</script>

By leveraging getters and setters in your computed properties, you can cut down on the amount of code you need to write in order to perform bindings between components and Vuex. You can also add additional logic, such as validation or data cleanup. Just make sure that you aren't overburdening your setters- if they start to get large, you might need to use a separate function anyway.

Conclusion

Computed properties can help ensure that your applications are easy to maintain and understandable. By leveraging a computed property, rather than manually assigning values, you allow the framework to do the heavy lifting for you. Computed properties are great for a number of complex tasks, such as:

  • Form status
  • API calls
  • Formulaic calculations (temperature conversion, weight conversion)
  • State-based CSS classes
  • Determining which state to render your component in.

Keep in mind - computed properties should not cause side effects. If you are writing a computed property that needs to alter your state, it should probably be a watcher.

Take a look at your own applications, and see where using a computed property could be beneficial. Have fun!

This Dot Labs is a modern web consultancy focused on helping companies realize their digital transformation efforts. For expert architectural guidance, training, or consulting in React, Angular, Vue, Web Components, GraphQL, Node, Bazel, or Polymer, visit thisdotlabs.com.

This Dot Media is focused on creating an inclusive and educational web for all. We keep you up to date with advancements in the modern web through events, podcasts, and free content. To learn, visit thisdot.co.

You might also like