Preventing Duplicate Button Submissions with Debounce, Loading State, and a Vue 3 Directive
This article explains how repeated button clicks cause duplicate submissions, demonstrates using debounce and a loading‑disabled state to mitigate the issue, and shows how to encapsulate the solution into a reusable Vue 3 directive with complete code examples.
Introduction
In everyday development, buttons are often clicked repeatedly by testers, leading to duplicate data submissions, especially when the backend response is slow.
Frontend developer: "Can you stop clicking so fast?"
Tester: "I don't know if the click succeeded, so I click many times."
Project manager: "This is a bug; the front‑end and back‑end should discuss a fix."
Backend developer: "It's not my problem; the request is slow, front‑end should handle it."
Debounce
Adding a debounce function can limit how often a click handler is invoked.
// pseudo‑code
Click me repeatedly
// debounce implementation omitted
const handleSubmit = debounce(submit, 1000);However, when the debounce interval (1 s) is shorter than the backend response time (5 s), testers can still trigger many requests within the waiting period.
Use Loading State
When the request starts, set a loading flag on the button and disable it; clear the flag after a successful response.
// pseudo‑code
Click me repeatedly
let loading = false;
function handleSubmit(){
loading = true; // enable loading
ajax('xxx/xxx/xxx').then(res => {
if(res.code == 200){
loading = false; // disable loading
}
})
}This solves the immediate bug but raises a new question: should each page define its own loading variable?
Vue 3 Directive Packaging
To reuse the loading logic, we encapsulate it into a global Vue directive.
// main.js (entry file)
import { createApp } from 'vue';
import App from './App.vue';
import { bLoading } from './permission/loading';
const app = createApp(App);
bLoading(app); // global registration // bLoading.js
import type { App } from 'vue';
let tag = null;
const className = `
el-icon {
--color: inherit;
align-items: center;
display: inline-flex;
height: 1em;
justify-content: center;
line-height: 1em;
position: relative;
width: 1em;
fill: currentColor;
color: var(--color);
font-size: inherit;
}
`;
const i = `
`;
export function bLoading(app: App
) {
app.directive('bLoading', (el, binding) => {
if (typeof binding.value !== 'function') {
throw new Error('Directive value must be a function');
}
el.addEventListener('click', () => {
addNode(el); // add loading icon
setTimeout(() => {
binding.value(() => {
cleanNode(el); // remove loading
});
});
});
});
}
function addNode(el) {
if (el.firstElementChild.tagName === 'I') {
tag = el.firstElementChild;
el.removeChild(el.firstElementChild);
el.insertAdjacentHTML('afterbegin', i);
} else {
el.insertAdjacentHTML('afterbegin', i);
}
el.setAttribute('disabled', true);
rotate('loading');
}
function cleanNode(el) {
el.removeAttribute('disabled');
if (el.firstElementChild) {
el.removeChild(el.firstElementChild);
if (tag) {
el.prepend(tag);
}
}
}
function rotate(id) {
const element = document.getElementById(id);
let angle = 0;
const speed = 2;
function rotate() {
angle = (angle + speed) % 360;
element.style.transform = `rotate(${angle}deg)`;
requestAnimationFrame(rotate);
}
rotate();
}Page Usage
<el-button type="primary" v-bLoading="(next) => handleSubmit(next)" :icon="Plus">Click me repeatedly</el-button>
import { Plus } from '@element-plus/icons-vue';
function handleSubmit(next){
// simulate async request
setTimeout(() => {
// request returns 200
next(); // clear loading
}, 3000);
}Conclusion
By turning the loading logic into a Vue directive, duplicate submissions are prevented, code duplication is reduced, and the solution becomes easily reusable across multiple pages.
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.