Search code examples
vue.jsvuejs3vuetify.js

Revert Vuetify toggle button when api call fails


I'm working with Vue and Vuetify 2.

I have a very simple component that uses Vuetify's v-btn-toggle:

<template>
  <v-btn-toggle v-model="localProp">
    <v-btn value="1">Value1</v-btn>
    <v-btn value="2">Value2</v-btn>
    <v-btn value="3">Value3</v-btn>
  </v-btn-toggle>
</template>

<script>
export default {
  name: 'TestToggle',
  props: {
    myProp: { type: String, default: null },
  },
  computed: {
    localProp: {
      get() {
        return this.myProp;
      },
      set(value) {
        this.$emit('change', value);
      },
    },
  },
};
</script>

In my parent component, I'm using like this:

<test-toggle :my-prop="obj.prop" @change="onChange"></test-toggle>
async onChange(prop) {
      const oldValue = this.obj.prop;
      const objUpdated = {
        ...this.obj,
        prop
      };
      try {
        await myApiCall(objUpdated );
      } catch {
        this.obj.prop = oldValue;
      }
    },

What I want is to revert the state of the toggle button if the API fails, to avoid that the user believes is action has been taken into account if the backend fails.

My problem is that it doesn't work. If I inspect the values with Vue Devtools:

  • In the component, myProp and localProp have been correctly reverted
  • In the VBtnToggle component, his value props has been reverted
  • BUT the VBtnToggle component has in its data an internalLazyValue that has NOT been reverted. I guess it's this data that is responsible of which toggle button is selected.

I've tried another thing: if, in my catch block, instead of reverting the value to the old value, I set it to null (to unselect all buttons), it works but only once !

The first time all props and values, including internalLazyValue, are null. The next times, everybody but internalLazyValue is null. internalLazyValue keeps the selected value.

Can anyone explain this behavior and/or tell me how I can achieve this revert mecanism ?

Thank you very much

Clément


Solution

  • The problem is that you never change this.obj.prop in the parent. When you supposedly set it back to the old value in the catch (this.obj.prop = oldValue;), you are actually setting it to the value it already has, so no update is triggered.

    If you update the value before setting it back, it works as expected:

    async onChange(prop) {
          const oldValue = this.obj.prop;
          this.obj.prop = prop // <---- update value before setting it back
          const objUpdated = {
            ...this.obj,
            prop
          };
          try {
            await myApiCall(objUpdated );
          } catch {
            this.obj.prop = oldValue;
          }
        },
    

    Consider disabling the input while the async call is running to avoid update anomalies.

    Here it is in a snippet:

    const TestToggle = {
      template: `
        <v-btn-toggle v-model="localProp">
          <v-btn value="1">Value1</v-btn>
          <v-btn value="2">Value2</v-btn>
          <v-btn value="3">Value3</v-btn>
        </v-btn-toggle>
      `,
      props: {
        myProp: { type: String, default: null },
      },
      computed: {
        localProp: {
          get() {
            return this.myProp;
          },
          set(value) {
            this.$emit('change', value);
          },
        },
      },
    }
    
    new Vue({
      components: {TestToggle},
      el: '#app',
      vuetify: new Vuetify(),
      template: `<div>
        <test-toggle :my-prop="selectedValue" @change="onChange"></test-toggle>
      Selected Value: {{selectedValue}}
      </div>`,
      data(){
        return {selectedValue: null}
      },
      methods: {
        async onChange(value) {
          const oldValue = this.selectedValue;
          this.selectedValue = value;
          await new Promise(resolve => setTimeout(resolve, 1000))
          this.selectedValue = oldValue;
        },
      }
    })
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/@mdi/[email protected]/css/materialdesignicons.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.min.css" rel="stylesheet">
    
    <div id="app"></div>
    
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.js"></script>


    Alternatively, you could use a :key prop to force updates manually.

    <test-toggle
      :my-prop="selectedValue"
      @change="onChange" 
      :key="forcedUpdates"
    />
    

    and in your onChange():

    async onChange(prop) {
          ...
          try {
            await myApiCall(objUpdated );
          } catch {
            this.forcedUpdates++;
          }
        },
    
    

    But this is a bit smelly if not outright bad practice, as you usually should try to work within the reactive framework.

    Here is another snippet:

    const TestToggle = {
      template: `
      <div>
        <v-btn-toggle v-model="localProp">
          <v-btn value="1">Value1</v-btn>
          <v-btn value="2">Value2</v-btn>
          <v-btn value="3">Value3</v-btn>
        </v-btn-toggle>
        {{localProp}}
      </div>
      `,
      props: {
        myProp: { type: String, default: null },
      },
      computed: {
        localProp: {
          get() {
            return this.myProp;
          },
          set(value) {
            this.$emit('change', value);
          },
        },
      },
    }
    
    new Vue({
      components: {TestToggle},
      el: '#app',
      vuetify: new Vuetify(),
      template: `<div>
        <p>(even values will be rejected)</p>
        <test-toggle :my-prop="selectedValue" @change="onChange" :key="forcedUpdates"></test-toggle>
        <div>Selected Value: {{selectedValue}}</div>
      </div>`,
      data(){
        return {selectedValue: "1", forcedUpdates: 0}
      },
      methods: {
        async onChange(value) {
          const oldValue = this.selectedValue;
          await new Promise(resolve => setTimeout(resolve, 1000))
          if (value % 2 === 1){
            this.selectedValue = value;
          } else {
            this.forcedUpdates++
          }
        },
      }
    })
    <link href="https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/@mdi/[email protected]/css/materialdesignicons.min.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.min.css" rel="stylesheet">
    
    <div id="app"></div>
    
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.js"></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vuetify.js"></script>