Using stateful functions on the methods block of the options API in Vue doesn’t work how you might expect.
A common pitfall when developing Vue apps, and one of the hardest ones to pinpoint and debug its issues, is using stateful functions on the methods
block of the options API.
Let’s start with a little background. A stateful function is a function that retains certain state between executions. In most cases, these functions are higher order functions—functions that return a function.
A great example of a higher order stateful function, and one that is the culprit in many of these scenarios, is Lodash’s debounce
. It’s a super handy utility function that takes a function as its main parameter and returns a debounced function.
To understand what debouncing means, as explained by Lodash’s docs: “delays invoking [a function] until after [certain] milliseconds have elapsed since the last time the debounced function was invoked.” Or, in simpler terms, it prevents a function from being called repeatedly until a certain amount of time has passed.
In order to better understand the problem first we have to create a few components and a sample application to demonstrate the issue.
Reusable.vue
<template>
<p>My amazing counter</p>
<button @click="addToCounterDebounced">+</button>
State: {{ counter }}
</template>
<script>
import { debounce } from "lodash";
export default {
data: () => ({
counter: 0,
}),
methods: {
addToCounter() {
this.counter++;
},
addToCounterDebounced: debounce(function() {
this.counter++;
}, 2000),
},
};
</script>
Let’s dive into it.
addToCounterDebounce
function when clicked.addToCounterDebounce
function is declared within the methods
block of the options API, and calls debounce
to generate the debounced function.debounce
call we are creating an anonymous function that increases the component’s counter
property by 1.2000
millisecond (2 seconds) debounce timer for this function, which means that if this function is called multiple times within 2 seconds of the last call—it will only be executed once. Remember this!counter
under the button so we can easily see it execute.The component after clicking it once:
Even if we click the button 100 times, the state will only increase by once every 2 seconds that there is no function being called, this is the nature of debounced methods.
If you were wondering about debounce and when to use it, a good use cases for debounced functions in real world scenarios are to delay user’s input from making API calls.
For example, an email
input field that pings the server to check if the email is already in use. You probably don’t want to ping the server for every single keystroke or change, and rather want to wait until the user is done typing to fire the method.
If you were to mount this component in an app and use it by itself, you probably would see absolutely no problem. You could even write some unit tests and everything would work perfectly.
But what would happen when two instances of the same component are created?
App.vue
<template>
<div>
<Reusable />
<Reusable />
</div>
</template>
<script>
import Reusable from "./Reusable.vue";
export default {
components: { Reusable }
};
</script>
Notice that we have imported the Reusable
component we created earlier into our app, and created two different instances of it on lines 3 and 4. Everything should work the same, right? Right?
Go ahead and give it a shot. Click on both
state +
buttons a few times within the two second window. We would expect both states to get a +1.
Hmmm… No matter how many times we click on both components, only the one where we clicked last will get updated. But why?
As we learned in the beginning of the article, debounce
is a stateful function—it keeps internal state while the execution of the component is in progress. When we start clicking around, the function receives the reference to the clicked component’s counter
property.
However, when we click on the second button, the counter
reference is replaced by the second component’s counter
. The first reference is “forgotten,” as the function is awaiting the timer to complete to execute its internal call of this.counter++
.
Thankfully, the solution is straightforward and also a good practice to keep for any use of stateful functions and higher order functions within Vue components.
Reusable.vue
<template>
<p>My amazing counter</p>
<button @click="addToCounter">+</button>
State: {{ counter }}
</template>
<script>
import { debounce } from "lodash";
export default {
data: () => ({
counter: 0,
}),
mounted() {
this.debouncedAddToCounter = debounce(function () {
this.counter++;
}, 2000);
},
methods: {
addToCounter() {
this.counter++;
},
addToCounter() {
this.debouncedAddToCounter();
},
},
};
</script>
Let’s take a look.
mounted
block, we assign the result of our debounce
call to a debounceAddToCounter
as a non-reactive property. Now the debounced function will only work within the scope of each instance.addToCounterDebounced
to addToCounter
for clarity, and replace it on the button’s click
listener.addToCounter
we can now call this.debouncedAddToCounter
.Go ahead and give this a shot, and click away at both buttons.
Now both of our counters update separately as intended.
This pitfall is more common that it should be, so hopefully after reading this article you will be ready and on the lookout for possible nasty little bugs created by misuse of stateful functions in Vue components.
Happy coding!
Marina Mosti is a frontend web developer with over 18 years of experience in the field. She enjoys mentoring other women on JavaScript and her favorite framework, Vue, as well as writing articles and tutorials for the community. In her spare time, she enjoys playing bass, drums and video games.