Implementing a Debounced Watcher in Vue 3 with TypeScript
Observing data changes in a Vue application is a common task, often achieved using watch or watchEffect. However, when the observed property can change rapidly, directly triggering an effect on every change can lead to performance issues or unintended side effects, such as excessive API calls or UI updates. This challenge requires you to implement a custom debounced watcher that delays the execution of a callback function until a specified period of inactivity has passed after the last change.
Problem Description
Your task is to create a composable function in Vue 3, written in TypeScript, that provides a debounced version of Vue's reactivity system. This composable should accept a Vue ref or reactive object (or a getter function) and a callback function, along with a delay in milliseconds. The callback should only be executed after the observed value has stopped changing for the specified delay.
Key Requirements:
- Composable Function: Create a function (e.g.,
useDebouncedWatch) that can be used within Vue'ssetupfunction or other composables. - Debouncing Logic: Implement the core debouncing mechanism. The callback should be invoked only after the
delayperiod has elapsed without any new changes to the observed source. - Cancellation: If the observed source changes again before the delay has finished, the previous pending execution of the callback should be canceled, and a new delay timer should be started.
- Immediate Execution (Optional but Recommended): Consider an option to execute the callback immediately on the first change, in addition to the debounced execution after the delay. This is a common feature in debouncing utilities.
- Type Safety: Ensure the composable is fully type-safe using TypeScript, correctly inferring types for the observed source and the callback arguments.
- Cleanup: The composable should handle cleanup of timers when the component using it is unmounted to prevent memory leaks.
Expected Behavior:
- When the watched source changes, a timer is started (or reset).
- If the watched source changes again before the timer finishes, the timer is reset.
- Only when the timer completes without further changes will the callback function be executed with the latest value of the watched source.
- If the
immediateoption is true, the callback will execute immediately on the first change, and then subsequent executions will be debounced.
Edge Cases to Consider:
- Rapid, frequent changes to the watched source.
- The initial value of the watched source.
- The delay being 0 or a negative number (handle gracefully, perhaps defaulting to immediate execution or no debouncing).
- The watched source being undefined or null initially.
Examples
Example 1: Basic Debouncing
Let's say you have an input field and you want to perform a search only after the user has stopped typing for 500ms.
<template>
<input v-model="searchQuery" placeholder="Type to search...">
<p>Searching for: {{ searchQuery}} (Debounced state)</p>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
import { useDebouncedWatch } from './useDebouncedWatch'; // Assuming your composable is here
export default defineComponent({
setup() {
const searchQuery = ref('');
// Debounce the search action. The callback will run 500ms after the user stops typing.
useDebouncedWatch(searchQuery, (newValue, oldValue) => {
console.log(`Performing search for: ${newValue}. Previous value was: ${oldValue}`);
// In a real app, you'd trigger an API call here.
}, { delay: 500 });
return {
searchQuery,
};
},
});
</script>
Explanation: As the user types, searchQuery updates. However, the console.log inside the useDebouncedWatch callback will only appear 500ms after the user pauses typing. If they type again within that 500ms, the timer resets.
Example 2: Debouncing with immediate: true
Suppose you want to save a form's state periodically, but also want an immediate save on the first change, then subsequent saves are debounced.
<template>
<textarea v-model="formData.content" placeholder="Enter content..."></textarea>
<p>Last saved: {{ lastSavedTimestamp }}</p>
</template>
<script lang="ts">
import { defineComponent, ref, reactive } from 'vue';
import { useDebouncedWatch } from './useDebouncedWatch';
export default defineComponent({
setup() {
const formData = reactive({
content: '',
});
const lastSavedTimestamp = ref<number | null>(null);
// Debounce saving. Callback runs immediately on first change, then every 1000ms of inactivity.
useDebouncedWatch(
() => formData.content, // Watch a getter for reactive objects
(newValue) => {
console.log('Saving form content...');
lastSavedTimestamp.value = Date.now();
// In a real app, this would be an API call to save the form.
},
{ delay: 1000, immediate: true }
);
return {
formData,
lastSavedTimestamp,
};
},
});
</script>
Explanation: When the formData.content changes for the first time, the callback runs immediately, and lastSavedTimestamp is updated. If the user continues typing, subsequent saves will only occur 1000ms after they stop typing.
Example 3: Debouncing a ref with a custom getter
Watching a deeply nested property of a reactive object.
<script lang="ts">
import { defineComponent, ref, reactive } from 'vue';
import { useDebouncedWatch } from './useDebouncedWatch';
interface UserProfile {
name: string;
address: {
street: string;
city: string;
};
}
export default defineComponent({
setup() {
const userProfile = reactive<UserProfile>({
name: 'Alice',
address: {
street: '123 Main St',
city: 'Anytown',
},
});
const debouncedCityChange = ref<string | null>(null);
// Debounce changes to the user's city
useDebouncedWatch(
() => userProfile.address.city,
(newCity) => {
console.log(`User's city has changed to: ${newCity}. Triggering update.`);
debouncedCityChange.value = newCity;
// e.g., fetch relevant data based on the new city
},
{ delay: 300 }
);
// Simulate updating the city after a delay
setTimeout(() => {
userProfile.address.city = 'Otherville';
}, 1000);
setTimeout(() => {
userProfile.address.city = 'Newville'; // This will reset the timer
}, 2000);
return {
userProfile,
debouncedCityChange,
};
},
});
</script>
Explanation: The callback will trigger with "Newville" approximately 300ms after the last userProfile.address.city update at 2000ms. The intermediate change to "Otherville" would have reset the timer.
Constraints
- The composable function should be compatible with Vue 3's Composition API.
- The
delayparameter will be a non-negative integer representing milliseconds. Adelayof0should result in immediate execution of the callback on every change (effectively behaving like a regularwatchbut with the debouncing pattern). - The composable should handle watching Vue
refs,reactiveobjects, and getter functions that return a value. - The callback function should receive the
newValueandoldValueof the watched source as arguments. If a getter function is used,oldValuemight be trickier to determine precisely without more complex internal tracking; focus on providing thenewValueaccurately. - Performance is important. Avoid unnecessary re-renders or excessive memory usage, especially in scenarios with very high-frequency updates.
- Your solution should be implemented entirely in TypeScript.
Notes
- Consider using
nextTickorwatchinternally to manage when the debounced function actually gets scheduled for execution to align with Vue's reactivity cycle. - Think about how to handle the
oldValuewhen a getter function is provided as the source. You might need to keep track of the previous value internally. - The
immediateoption is a common pattern and highly recommended for a robust debouncing utility. - The
unmountcleanup is crucial. Vue'sonUnmountedlifecycle hook is your friend here. - This challenge is an excellent opportunity to practice TypeScript generics and advanced composable patterns.