Hone logo
Hone
Problems

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:

  1. Composable Function: Create a function (e.g., useDebouncedWatch) that can be used within Vue's setup function or other composables.
  2. Debouncing Logic: Implement the core debouncing mechanism. The callback should be invoked only after the delay period has elapsed without any new changes to the observed source.
  3. 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.
  4. 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.
  5. Type Safety: Ensure the composable is fully type-safe using TypeScript, correctly inferring types for the observed source and the callback arguments.
  6. 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 immediate option 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 delay parameter will be a non-negative integer representing milliseconds. A delay of 0 should result in immediate execution of the callback on every change (effectively behaving like a regular watch but with the debouncing pattern).
  • The composable should handle watching Vue refs, reactive objects, and getter functions that return a value.
  • The callback function should receive the newValue and oldValue of the watched source as arguments. If a getter function is used, oldValue might be trickier to determine precisely without more complex internal tracking; focus on providing the newValue accurately.
  • 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 nextTick or watch internally to manage when the debounced function actually gets scheduled for execution to align with Vue's reactivity cycle.
  • Think about how to handle the oldValue when a getter function is provided as the source. You might need to keep track of the previous value internally.
  • The immediate option is a common pattern and highly recommended for a robust debouncing utility.
  • The unmount cleanup is crucial. Vue's onUnmounted lifecycle hook is your friend here.
  • This challenge is an excellent opportunity to practice TypeScript generics and advanced composable patterns.
Loading editor...
typescript