Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions .github/workflows/linter.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
name: Code Quality
on:
pull_request:
branches:
- main

concurrency:
group: linter-${{ github.ref }}
cancel-in-progress: true

jobs:
test:
timeout-minutes: 60
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
- name: Enable Corepack
run: corepack enable

- name: Install dependencies
run: yarn install --immutable

- name: Prettier check
run: yarn format:check
2 changes: 0 additions & 2 deletions .yarnrc.yml
Original file line number Diff line number Diff line change
@@ -1,3 +1 @@
nodeLinker: node-modules

yarnPath: .yarn/releases/yarn-4.9.1.cjs
8 changes: 7 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,16 @@
"packageManager": "yarn@4.9.1",
"scripts": {
"test": "vitest",
"format": "prettier --write ."
"format": "prettier --write .",
"format:check": "prettier --check ."
},
"devDependencies": {
"@testing-library/dom": "^10.4.0",
"@testing-library/react": "^16.3.0",
"jsdom": "^26.1.0",
"prettier": "3.5.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"vitest": "^3.1.2"
}
}
42 changes: 42 additions & 0 deletions src/ts/use-debounced-callback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useRef } from 'react';

export default function useDebouncedCallback<
T extends (...args: any[]) => void,
>(callback: T, delay: number) {
const timeout = useRef<ReturnType<typeof setTimeout>>();
const lastArgs = useRef<Parameters<T>>();

function debouncedFunction(...args: Parameters<T>) {
if (timeout.current) {
clearTimeout(timeout.current);
}

lastArgs.current = args;

timeout.current = setTimeout(() => {
callback(...args);
}, delay);
}

function cancel() {
if (timeout.current) {
clearTimeout(timeout.current);
timeout.current = undefined;
}
}

function flush() {
if (timeout.current) {
clearTimeout(timeout.current);
callback(...lastArgs.current);
timeout.current = undefined;
lastArgs.current = undefined;
}
}

return {
debounced: debouncedFunction,
cancel,
flush,
};
}
77 changes: 77 additions & 0 deletions src/ts/use-debounced-callback/use-debounced-callback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { expect, it, describe, vi, beforeEach } from 'vitest';
import { renderHook } from '@testing-library/react';
import useDebouncedCallback from '.';

const getSomeData = vi.fn(
(id: number, quantity: number, options: { obj?: boolean }) => {
return {
id,
quantity,
...options,
};
}
);
const debounceTime = 10;

describe('useDebouncedCallback', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('Should debounce callbacks and execute the last one for each key', async () => {
const { result } = renderHook(() =>
useDebouncedCallback(getSomeData, debounceTime)
);

// Call the debounced callback without key, it will debounce by default
result.current.debounced(1, 1, { obj: false });
result.current.debounced(1, 4, { obj: true });
result.current.debounced(1, 4, { obj: false });
result.current.debounced(1, 2, { obj: false });
result.current.debounced(1, 5, { obj: false });
result.current.debounced(1, 8, { obj: true });

// Wait for the debounce time to elapse
await new Promise((resolve) => setTimeout(resolve, debounceTime + 10));

expect(getSomeData).toHaveBeenCalledTimes(1);
expect(getSomeData).toHaveBeenCalledWith(1, 8, { obj: true });
});

it('Should not debounce if flush', async () => {
const { result } = renderHook(() =>
useDebouncedCallback(getSomeData, 10000000)
);

// Call the debounced callback without key, it will debounce by default
result.current.debounced(1, 1, { obj: false });
result.current.debounced(1, 4, { obj: true });
result.current.debounced(1, 4, { obj: false });
result.current.debounced(1, 2, { obj: false });
result.current.debounced(1, 5, { obj: false });
result.current.debounced(1, 8, { obj: true });

result.current.flush();

expect(getSomeData).toHaveBeenCalledTimes(1);
expect(getSomeData).toHaveBeenCalledWith(1, 8, { obj: true });
});

it('Should cancel debounce callback', async () => {
const { result } = renderHook(() =>
useDebouncedCallback(getSomeData, debounceTime)
);

// Call the debounced callback without key, it will debounce by default
result.current.debounced(1, 1, { obj: false });
result.current.debounced(1, 4, { obj: true });
result.current.debounced(1, 4, { obj: false });
result.current.debounced(1, 2, { obj: false });
result.current.debounced(1, 5, { obj: false });
result.current.debounced(1, 8, { obj: true });

result.current.cancel();

expect(getSomeData).toHaveBeenCalledTimes(0);
});
});
92 changes: 92 additions & 0 deletions src/ts/use-keyed-debounced-callback/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
import { useRef } from 'react';

type DebounceKey = string | symbol | number;
const DEBOUNCE_ERR_KEY = 'Key is required for debouncing';

/**
* A custom hook that returns a debounced function, which delays the execution
* of the provided callback until after a specified delay has elapsed since the
* last time it was invoked with the same key.
*
* @template T - The type of the callback function.
* @param callback - The function to be debounced. It will be executed after the
* delay period if no further calls are made with the same key.
* @param delay - The delay in milliseconds to wait before invoking the callback.
* @returns A debounced function that accepts a unique key and the arguments
* to pass to the callback. The key is used to manage separate debounce timers.
*
* @throws {TypeError} If the provided key is not a valid primitive type (e.g.,
* string, number, or symbol).
*/
export default function useKeyedDebouncedCallback<
T extends (...args: any[]) => void,
>(callback: T, delay: number) {
const timeouts = useRef(
new Map<DebounceKey, ReturnType<typeof setTimeout>>()
);
const lastArgs = useRef(new Map<DebounceKey, Parameters<T>>());

function debouncedFunction(key: DebounceKey, ...args: Parameters<T>) {
if (!key || ['object', 'function'].includes(typeof key)) {
throw new TypeError(DEBOUNCE_ERR_KEY);
}

// Set the last arguments for the key
lastArgs.current.set(key, args);

if (timeouts.current.has(key)) {
clearTimeout(timeouts.current.get(key));
}

const timeout = setTimeout(() => {
callback(...args);
timeouts.current.delete(key);
}, delay);

timeouts.current.set(key, timeout);
}

function cancel(key: DebounceKey) {
if (timeouts.current.has(key)) {
clearTimeout(timeouts.current.get(key));
timeouts.current.delete(key);
}
}

function flush(key: DebounceKey) {
if (timeouts.current.has(key)) {
clearTimeout(timeouts.current.get(key));
callback(...lastArgs.current.get(key));
timeouts.current.delete(key);
lastArgs.current.delete(key);
}
}

function flushAll() {
timeouts.current.forEach((timeout, key) => {
clearTimeout(timeout);
callback(...lastArgs.current.get(key));
timeouts.current.delete(key);
lastArgs.current.delete(key);
});
}

function cancelAll() {
timeouts.current.forEach((timeout, key) => {
clearTimeout(timeout);
timeouts.current.delete(key);
lastArgs.current.delete(key);
});
}

return {
debounce: debouncedFunction,
cancel,
flush,
flushAll,
cancelAll,
get timeouts() {
return timeouts.current;
},
};
}
Loading