Modulator is an advanced debouncing utility, now written in TypeScript, designed to optimize high-frequency events in web applications (e.g., scroll, resize, input). This standalone solution offers enhanced performance and flexibility compared to basic debouncing functions.
Key features include:
- Promise-based Return: Always returns a Promise that resolves with the result of your function or rejects on error/cancellation.
- Configurable Caching: Optional result caching based on arguments with controllable
maxCacheSize
. - Immediate Execution: Option (
immediate: true
) to trigger the function on the leading edge. - Maximum Wait Time: Optional
maxWait
parameter to guarantee execution after a certain period, even with continuous calls. - Cancellation: A
.cancel()
method to abort pending debounced calls and reject their associated Promise. - TypeScript Support: Ships with built-in type definitions for a better developer experience.
npm install @danielhaim/modulator
# or
yarn add @danielhaim/modulator
import { modulate } from '@danielhaim/modulator';
// or import default Modulator from '@danielhaim/modulator'; // If using the object wrapper (less common now)
async function myAsyncFunction(query) {
console.log('Executing with:', query);
// Simulate work
await new Promise(res => setTimeout(res, 50));
if (query === 'fail') throw new Error('Failed!');
return `Result for ${query}`;
}
const debouncedFunc = modulate(myAsyncFunction, 300);
debouncedFunc('query1')
.then(result => console.log('Success:', result)) // Logs 'Success: Result for query1' after 300ms
.catch(error => console.error('Caught:', error));
debouncedFunc('fail')
.then(result => console.log('Success:', result))
.catch(error => console.error('Caught:', error)); // Logs 'Caught: Error: Failed!' after 300ms
// Using async/await
async function run() {
try {
const result = await debouncedFunc('query2');
console.log('Async Success:', result);
} catch (error) {
console.error('Async Error:', error);
}
}
run();
const { modulate } = require('@danielhaim/modulator');
const debouncedFunc = modulate(/* ... */);
// ... usage is the same
Include the UMD build:
<!-- Download dist/modulator.umd.js or use a CDN like jsDelivr/unpkg -->
<script src="path/to/modulator.umd.js"></script>
<script>
// Modulator is available globally
const debouncedFunc = Modulator.modulate(myFunction, 200);
myButton.addEventListener('click', async () => {
try {
const result = await debouncedFunc('data');
console.log('Got:', result);
} catch (e) {
console.error('Error:', e);
}
});
</script>
requirejs(['path/to/modulator.amd'], function(Modulator) {
const debouncedFunc = Modulator.modulate(myFunction, 200);
// ...
});
Creates a debounced function that delays invoking func
until after wait
milliseconds have elapsed since the last time the debounced function was invoked.
Returns: DebouncedFunction
- A new function that returns a Promise
. This promise resolves with the return value of the original func
or rejects if func
throws an error, returns a rejected promise, or if the debounced call is cancelled via .cancel()
.
Name | Type | Attributes | Default | Description |
---|---|---|---|---|
func |
Function |
The function to debounce. Can be synchronous or asynchronous (return a Promise). | ||
wait |
number |
The debouncing wait time in milliseconds. Must be non-negative. | ||
immediate? |
boolean |
<optional> |
false |
If true , triggers func on the leading edge instead of the trailing edge. Subsequent calls within the wait period are ignored until the cooldown finishes. |
context? |
object |
<optional> |
null |
The context (this ) to apply when invoking func . Defaults to the context the debounced function is called with. |
maxCacheSize? |
number |
<optional> |
100 |
The maximum number of results to cache based on arguments. Uses JSON.stringify for keys. Set to 0 to disable caching. Must be non-negative. |
maxWait? |
number | null |
<optional> |
null |
The maximum time (in ms) func is allowed to be delayed before it's invoked, even if calls keep occurring. Must be >= wait if set. |
The returned debounced function has an additional method:
debouncedFunc.cancel()
: Cancels any pending invocation of the debounced function. If a call was pending, thePromise
returned by that call will be rejected with an error indicating cancellation. This does not clear the result cache.
- When
maxCacheSize > 0
, Modulator caches the results (resolved values) of successfulfunc
invocations. - The cache key is generated using
JSON.stringify(arguments)
. This works well for primitive arguments but may have limitations with complex objects, functions, or circular references. - If a subsequent call is made with the same arguments (generating the same cache key) while the result is in the cache, the cached result is returned immediately via a resolved Promise, and
func
is not invoked. - The cache uses a simple Least Recently Used (LRU) eviction strategy: when the cache exceeds
maxCacheSize
, the oldest entry is removed. Accessing a cached item marks it as recently used.
function handleInput(value) {
console.log('Processing input:', value);
// e.g., make API call
}
// Debounce to run only 500ms after the user stops typing
const debouncedHandleInput = modulate(handleInput, 500);
searchInput.addEventListener('input', (event) => {
debouncedHandleInput(event.target.value)
.catch(err => console.error("Input Error:", err)); // Optional: Catch potential errors
});
function handleClick() {
console.log('Button clicked!');
// Perform action immediately, but prevent rapid re-clicks
}
// Trigger immediately, then ignore calls for 1000ms
const debouncedClick = modulate(handleClick, 1000, true);
myButton.addEventListener('click', () => {
debouncedClick().catch(err => {
// Only log if it's not a cancellation error, as we don't cancel here
if (err.message !== 'Debounced function call was cancelled.') {
console.error("Click Error:", err);
}
});
});
async function searchAPI(query) {
if (!query) return []; // Handle empty query
console.log(`Searching API for: ${query}`);
const response = await fetch(`/api/search?q=${query}`);
if (!response.ok) throw new Error(`API Error: ${response.statusText}`);
return response.json();
}
const debouncedSearch = modulate(searchAPI, 400);
const statusElement = document.getElementById('search-status'); // Assume element exists
const searchInput = document.getElementById('search-input'); // Assume element exists
searchInput.addEventListener('input', async (event) => {
const query = event.target.value;
statusElement.textContent = 'Searching...';
try {
// debouncedSearch returns a promise here
const results = await debouncedSearch(query);
// Check if query is still relevant before updating UI
if (query === searchInput.value) {
statusElement.textContent = `Found ${results.length} results.`;
// Update UI with results
} else {
console.log("Query changed, ignoring results for:", query);
}
} catch (error) {
// Handle errors from searchAPI OR cancellation errors
if (error.message === 'Debounced function call was cancelled.') {
console.log('Search cancelled.');
// Status might already be 'Searching...' which is fine
} else {
console.error('Search failed:', error);
statusElement.textContent = `Error: ${error.message}`;
}
}
});
// Example of cancellation (Alternative approach combining input/cancel)
let currentQuery = '';
searchInput.addEventListener('input', async (event) => {
const query = event.target.value;
currentQuery = query;
statusElement.textContent = 'Typing...';
// Cancel any previous pending search before starting a new one
debouncedSearch.cancel(); // Cancel previous timer/promise
if (!query) { // Handle empty input immediately
statusElement.textContent = 'Enter search term.';
// Clear results UI
return;
}
// Only proceed if query is not empty after debounce period
try {
statusElement.textContent = 'Waiting...'; // Indicate waiting for debounce
// Start new search (will wait 400ms unless cancelled again)
const results = await debouncedSearch(query); // New promise for this call
// Re-check if the query changed *after* the await completed
if (query === currentQuery) {
statusElement.textContent = `Found ${results.length} results.`;
// Update UI
} else {
console.log('Results ignored, query changed.');
// Status might remain 'Typing...' from next input event
}
} catch (error) {
// Handle errors from the awaited promise
if (error.message !== 'Debounced function call was cancelled.') {
console.error('Search failed:', error);
statusElement.textContent = `Error: ${error.message}`;
} else {
// Ignore cancellation errors here as we trigger cancel often
console.log('Search promise cancelled.');
}
}
});
function saveData() {
console.log('Saving data to server...');
// API call to save
return Promise.resolve({ status: 'Saved' }); // Example return
}
// Debounce saving by 1 second, but ensure it saves
// at least once every 5 seconds even if user keeps typing.
const debouncedSave = modulate(saveData, 1000, false, null, 0, 5000); // No cache, maxWait 5s
const saveStatus = document.getElementById('save-status'); // Assume element exists
const textArea = document.getElementById('my-textarea'); // Assume element exists
textArea.addEventListener('input', () => {
saveStatus.textContent = 'Changes detected, waiting to save...';
debouncedSave()
.then(result => {
// Check if still relevant (optional)
saveStatus.textContent = `Saved successfully at ${new Date().toLocaleTimeString()}`;
console.log('Save result:', result);
})
.catch(err => {
if (err.message !== 'Debounced function call was cancelled.') {
console.error("Save Error:", err);
saveStatus.textContent = `Save failed: ${err.message}`;
} else {
console.log("Save cancelled.");
// Status remains 'waiting...' or might be updated by next input
}
});
});
- The Debouncing and Throttling Explained article on the CSS-Tricks website
- The Underscore.js documentation on the debounce function
- The Lodash documentation on the debounce function
If you encounter any bugs while using Modulator, please report them to the GitHub issue tracker. When submitting a bug report, please include as much information as possible, such as:
- Version of Modulator used.
- Browser/Node.js environment and version.
- Steps to reproduce the bug.
- Expected behavior vs. actual behavior.
- Any relevant code snippets.