├── .eslintrc ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── main.yml ├── .gitignore ├── .prettierrc ├── CHANGELOG.md ├── LICENSE ├── README.md ├── jest.config.js ├── logo.png ├── package-lock.json ├── package.json ├── setupTests.ts ├── src ├── index.ts ├── useDebounce.ts ├── useDebouncedCallback.ts └── useThrottledCallback.ts ├── test ├── useDebounce.test.tsx ├── useDebouncedCallback.test.tsx └── useThrottledCallback.test.tsx └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "@typescript-eslint/parser", 3 | "extends": [ 4 | "plugin:@typescript-eslint/recommended", 5 | "prettier", 6 | "plugin:prettier/recommended" 7 | ], 8 | "parserOptions": { 9 | "ecmaVersion": 2018, 10 | "sourceType": "module" 11 | }, 12 | "plugins": ["react-hooks"], 13 | "rules": { 14 | "react-hooks/rules-of-hooks": "error", 15 | "react-hooks/exhaustive-deps": "error", 16 | "semi": ["error", "always"], 17 | "space-before-function-paren": 0, 18 | "@typescript-eslint/explicit-function-return-type": 0, 19 | "@typescript-eslint/no-explicit-any": 0 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior. 15 | 16 | **Repro example** 17 | Please include links to codesandbox or other code sharing resource, so that we can reproduce & triage the issue quickly 18 | 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **use-debounce version:** 24 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe your idea** 11 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - master 7 | push: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v1 17 | - name: Use Node.js 21.x 18 | uses: actions/setup-node@v1 19 | with: 20 | node-version: 21.x 21 | - name: install and tests 22 | run: | 23 | npm install 24 | npm run test 25 | npm run size 26 | env: 27 | CI: true 28 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | .vscode 3 | examples/bundle.js 4 | node_modules 5 | npm-debug.js 6 | esm 7 | lib 8 | .git 9 | .DS_store 10 | coverage 11 | tmp 12 | yarn-error.log 13 | dist -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "printWidth": 80, 3 | "tabWidth": 2, 4 | "singleQuote": true, 5 | "trailingComma": "es5", 6 | "arrowParens": "always" 7 | } 8 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 10.0.4 2 | - Fix behaviour for strictMode react when leading is set to true and trailing is true 3 | 4 | ## 10.0.3 5 | - Removed `peerDependency` part from `package.json` as NPM cannot correctly resolve `peerDependency` for beta and rc versions: see https://stackoverflow.com/questions/67934358/npm-including-all-range-of-pre-release-when-defining-peer-dependency for context 6 | 7 | ## 10.0.2 8 | 9 | - Fixed: `isPending` does not reset the state if the tracked value hasn't changed.. See https://github.com/xnimorz/use-debounce/issues/178 10 | 11 | ## 10.0.1 12 | 13 | - Fixed flush method return args, thanks to [@h](https://github.com/h) 14 | 15 | ## 10.0.0 16 | 17 | - _Major breaking change_: replaced `index.modern.js` with `index.mjs`. Might require a little change in your build pipeline 18 | - _Major breaking change_: New option `debounceOnServer`: if you put the option to true, it will run debouncing on server (via `setTimeout`). The new option can break your current server behaviour, as v9.x, it runs all the time and might cause unnessesary server CPU utilisation. Now, by default, debounced callbacks do not happen on server. 19 | - _Minor breaking change_: Replaced `useState` for `useDebounce` with `useReducer`. It might lead to reduced amount of re-renders, as useState is known to have excess re-renders in some corner: https://stackoverflow.com/questions/57652176/react-hooks-usestate-setvalue-still-rerender-one-more-time-when-value-is-equal 20 | - _Minor breaking change_: `useDebouncedCallback` now updates function to call asap. Meaning, if you re-called the hook and it should trigger immediately, it will trigger the newest function all the time. 21 | - Lib size: 22 | 914 B: index.js.gz 23 | 851 B: index.js.br 24 | 883 B: index.mjs.gz 25 | 826 B: index.mjs.br 26 | 938 B: index.module.js.gz 27 | 873 B: index.module.js.br 28 | 989 B: index.umd.js.gz 29 | 919 B: index.umd.js.br 30 | - [Internal] Replaced Enzyme with @testing-library 31 | - [Internal] yarn classic => npm 32 | - [Internal] Updated devDependencies 33 | 34 | ## 9.0.4 35 | 36 | - Tweak exports, see [PR](https://github.com/xnimorz/use-debounce/pull/160), thanks to [@Andarist](https://github.com/Andarist) 37 | - Changed types, see [PR](https://github.com/xnimorz/use-debounce/pull/158), thanks to [@wangcch](https://github.com/wangcch) 38 | 39 | ## 9.0.3 40 | 41 | - Represent correct return type from useDebounce(), see [issue](https://github.com/xnimorz/use-debounce/pull/155), thanks to [@appden](https://github.com/appden) 42 | 43 | ## 9.0.2 44 | 45 | - Reverted 9.0.0. We will revisit these changes later 46 | 47 | ## 9.0.0 48 | 49 | - Moved use-debounce to support modules see [issue](https://github.com/xnimorz/use-debounce/issues/147) Thanks to [@matewilk](https://github.com/matewilk) 50 | - _breaking change_ The path to `dist/index.js` is changed. Now it's `dist/index.cjs`. 51 | 52 | ## 8.0.4 53 | 54 | - Changes types for `useDebouncedCallback` args: https://github.com/xnimorz/use-debounce/pull/140 Thanks to [@sarunast](https://github.com/sarunast) 55 | 56 | ## 8.0.3 57 | 58 | - Added `types` to package json to mitigate https://github.com/microsoft/TypeScript/issues/49160. https://github.com/xnimorz/use-debounce/pull/138 Thanks to [@wuzzeb](https://github.com/wuzzeb) 59 | 60 | ## 8.0.2 61 | 62 | - Added type exports. https://github.com/xnimorz/use-debounce/pull/136 Thanks to [@tryggvigy](https://github.com/tryggvigy) 63 | - Improved code comments. https://github.com/xnimorz/use-debounce/pull/135 Thanks to [@tryggvigy](https://github.com/tryggvigy) 64 | 65 | ## 8.0.1 66 | 67 | - update library exports section to make exports work correctly with jest@28 68 | 69 | ## 8.0.0 70 | 71 | - _breaking change_ `useDebounce` changed its build system to microbundle. For now we have several entries: 72 | 73 | `index.js` is for commonJS approach 74 | `index.modern.js` for esnext module system 75 | `index.umd.js` for UMD. 76 | All the files are in `dist` folder. 77 | 78 | If you have any paths which have `esm` or `lib`, please, replace them to `dist`: 79 | 80 | Before: 81 | 82 | ```js 83 | import useDebounceCallback from 'use-debounce/lib/useDebounceCallback'; 84 | ``` 85 | 86 | After: 87 | 88 | ```js 89 | import { useDebounceCallback } from 'use-debounce'; 90 | ``` 91 | 92 | - Fixed issue with incorrect VSCode autocomplete https://github.com/xnimorz/use-debounce/issues/131 Thanks to [@c-ehrlich](https://github.com/c-ehrlich) for reporting 93 | - Fixed `useDebounce` behaviour with react-devtools tab when devtools have a component with `useDebounce` or `useDebounceCallback` opened. https://github.com/xnimorz/use-debounce/issues/129 Thanks to [@alexniarchos](https://github.com/alexniarchos) for reporting 94 | - Fixed issue with `leading: true` https://github.com/xnimorz/use-debounce/issues/124 Thanks to [@mntnoe](https://github.com/mntnoe) for reporting 95 | 96 | ## 7.0.1 97 | 98 | - `debounced` object now is preserved for `use-debounce` between the renders. Thanks to [@msharifi99](https://github.com/msharifi99) for reporting. 99 | 100 | ## 7.0.0 101 | 102 | - _breaking change_ `useDebounce` hook changed `isPending` behavior from `async` reacting to the sync. Now `isPending` returns `True` as soon as the new value is sent to the hook. 103 | - Dev dependencies updated 104 | 105 | ## 6.0.1 106 | 107 | - Fixed `useDebouncedCallback` return type. Closed https://github.com/xnimorz/use-debounce/issues/103 thanks to [@VanTanev](https://github.com/VanTanev) 108 | 109 | ## 6.0.0 110 | 111 | - _breaking change_: removed `callback` field, instead of this `useDebouncedCallback` and `useThrottledCallback` returns a callable function: 112 | Old: 113 | 114 | ```js 115 | const { callback, pending } = useDebouncedCallback(/*...*/); 116 | // ... 117 | debounced.callback(); 118 | ``` 119 | 120 | New: 121 | 122 | ```js 123 | const debounced = useDebouncedCallback(/*...*/); 124 | // ... 125 | debounced(); 126 | /** 127 | * Also debounced has fields: 128 | * { 129 | * cancel: () => void 130 | * flush: () => void 131 | * isPending: () => boolean 132 | * } 133 | * So you can call debounced.cancel(), debounced.flush(), debounced.isPending() 134 | */ 135 | ``` 136 | 137 | It makes easier to understand which cancel \ flush or isPending is called in case you have several debounced functions in your component 138 | 139 | - _breaking change_: Now `useDebounce`, `useDebouncedCallback` and `useThrottledCallback` has `isPending` method instead of `pending` 140 | 141 | Old: 142 | 143 | ```js 144 | const { callback, pending } = useDebouncedCallback(/*...*/); 145 | ``` 146 | 147 | New: 148 | 149 | ```js 150 | const { isPending } = useDebouncedCallback(/*...*/); 151 | /** 152 | * { 153 | * cancel: () => void 154 | * flush: () => void 155 | * isPending: () => boolean 156 | * } 157 | */ 158 | ``` 159 | 160 | - get rid of `useCallback` calls 161 | 162 | - improve internal typing 163 | 164 | - decrease the amount of functions to initialize each `useDebouncedCallback` call 165 | 166 | - reduce library size: 167 | 168 | Whole library: from 946 B to 899 B === 47 B 169 | useDebounce: from 844 to 791 === 53 B 170 | useDebouncedCallback: from 680 to 623 === 57 B 171 | useThrottledCallback: from 736 to 680 === 56 B 172 | 173 | ## 5.2.1 174 | 175 | - prevent having ininite setTimeout setup when component gets unmounted https://github.com/xnimorz/use-debounce/issues/97 176 | - function type works correctly with `useDebounce` now. https://github.com/xnimorz/use-debounce/pull/95 Thanks to [@csu-feizao](https://github.com/csu-feizao) 177 | 178 | ## 5.2.0 179 | 180 | - Added `useThrottledCallback` 181 | 182 | ## 5.1.0 183 | 184 | — `wait` param is optional. If you don't provide a wait argument, use-debounce will postpone a callback with requestAnimationFrame if it's in browser environment, or through setTimeout(..., 0) otherwise. 185 | 186 | ## 5.0.4 187 | 188 | - Add an export for React Native 189 | 190 | ## 5.0.3 191 | 192 | - Fix the export map (https://github.com/xnimorz/use-debounce/issues/84); 193 | 194 | ## 5.0.2 195 | 196 | - Add size-limit and configure it for esm modules. Now the size of the whole library is limited within 1 KB (thanks to [@omgovich](https://github.com/omgovich)) 197 | 198 | - Add an [export map](https://docs.skypack.dev/package-authors/package-checks#export-map) to your package.json. (thanks to [@omgovich](https://github.com/omgovich)) 199 | 200 | - Reduce bundle size (thanks to [@omgovich](https://github.com/omgovich)): 201 | Before: 202 | 203 | ``` 204 | esm/index.js 205 | Size: 908 B with all dependencies, minified and gzipped 206 | 207 | esm/index.js 208 | Size: 873 B with all dependencies, minified and gzipped 209 | 210 | esm/index.js 211 | Size: 755 B with all dependencies, minified and gzipped 212 | ``` 213 | 214 | Now: 215 | 216 | ``` 217 | esm/index.js 218 | Size: 826 B with all dependencies, minified and gzipped 219 | 220 | esm/index.js 221 | Size: 790 B with all dependencies, minified and gzipped 222 | 223 | esm/index.js 224 | Size: 675 B with all dependencies, minified and gzipped 225 | ``` 226 | 227 | - Add notes about returned value from `debounced.callback` and its subsequent calls: https://github.com/xnimorz/use-debounce#returned-value-from-debouncedcallback 228 | 229 | - Add project logo (thanks to [@omgovich](https://github.com/omgovich)): 230 | use-debounce 231 | 232 | ## 5.0.1 233 | 234 | - Fix typing to infer correct callback type (thanks to [@lytc](https://github.com/lytc)) 235 | 236 | ## 5.0.0 237 | 238 | - _breaking change_: Now `useDebouncedCallback` returns an object instead of array: 239 | 240 | Old: 241 | 242 | ```js 243 | const [debouncedCallback, cancelDebouncedCallback, callPending] = 244 | useDebouncedCallback(/*...*/); 245 | ``` 246 | 247 | New: 248 | 249 | ```js 250 | const debounced = useDebouncedCallback(/*...*/); 251 | /** 252 | * debounced: { 253 | * callback: (...args: T) => unknown, which is debouncedCallback 254 | * cancel: () => void, which is cancelDebouncedCallback 255 | * flush: () => void, which is callPending 256 | * pending: () => boolean, which is a new function 257 | * } 258 | */ 259 | ``` 260 | 261 | - _breaking change_: Now `useDebounce` returns an array of 2 fields instead of a plain array: 262 | Old: 263 | 264 | ```js 265 | const [value, cancel, callPending] = useDebounce(/*...*/); 266 | ``` 267 | 268 | New: 269 | 270 | ```js 271 | const [value, fn] = useDebounce(/*...*/); 272 | /** 273 | * value is just a value without changes 274 | * But fn now is an object: { 275 | * cancel: () => void, which is cancel 276 | * flush: () => void, which is callPending 277 | * pending: () => boolean, which is a new function 278 | * } 279 | */ 280 | ``` 281 | 282 | - Added `pending` function to both `useDebounce` and `useDebouncedCallback` which shows whether component has pending callbacks 283 | Example: 284 | 285 | ```js 286 | function Component({ text }) { 287 | const debounced = useDebouncedCallback( 288 | useCallback(() => {}, []), 289 | 500 290 | ); 291 | 292 | expect(debounced.pending()).toBeFalsy(); 293 | debounced.callback(); 294 | expect(debounced.pending()).toBeTruthy(); 295 | debounced.flush(); 296 | expect(debounced.pending()).toBeFalsy(); 297 | 298 | return {text}; 299 | } 300 | ``` 301 | 302 | For more details of these major changes you could check this commit https://github.com/xnimorz/use-debounce/commit/1b4ac0432f7074248faafcfe6248df0be4bb4af0 and this issue https://github.com/xnimorz/use-debounce/issues/61 303 | 304 | - Fixed security alerts 305 | 306 | ## 4.0.0 307 | 308 | - _breaking change_: Support lodash style throttling options for trailing+maxWidth. Thanks to [@tryggvigy](https://github.com/tryggvigy) 309 | Example: 310 | 311 | ```js 312 | useDebouncedCallback(callback, 300, { 313 | leading: true, 314 | trailing: false, 315 | maxWait: 300, 316 | }); 317 | ``` 318 | 319 | Where the trailing edge is turned off. Let's say the function is called twice in the first 300ms. Now debounced function to have been called _once_. 320 | 321 | _how to migrate_: Please, check your `traling: false` params with `maxWait` option 322 | 323 | - _breaking change_: Now in case delay option is unset, it will be `requestAnimationFrame` delay 324 | 325 | - _breaking change_: Now `debouncedCallback` from `useDebouncedCallback` returns a value. In v3 it used to return `undefined`: 326 | 327 | ## 3.4.3 328 | 329 | - Fix use-debounce so that it works correctly with react-native and next.js (as both of them use fast-refresh). 330 | 331 | ## 3.4.2 332 | 333 | - Clear cache in build directory. Thanks to [@wangcch](https://github.com/wangcch) 334 | 335 | ## 3.4.1 336 | 337 | - update types, so that they are more convinient. Thanks to [@astj](https://github.com/astj) 338 | 339 | ## 3.4.0 340 | 341 | - Now `callPendings` wrapped with useCallback hook, so that the reference to the function would be the same. Thanks to [@jfschwarz](https://github.com/jfschwarz) 342 | 343 | ## 3.3.0 344 | 345 | - `useDebouncedCallback` and `useDebounce` now can configure both `leading` and `trailing` options. They are fully compatible with `lodash.debounce` function https://lodash.com/docs/4.17.15#debounce. `leading` by default is false, trailing by default is true. 346 | Examples: https://codesandbox.io/s/vigilant-bush-zrbzg 347 | https://github.com/xnimorz/use-debounce/blob/master/test/useDebouncedCallback.test.tsx#L29-L180 348 | 349 | ## 3.2.0 350 | 351 | - `useDebounce` has `callPending` method. See https://github.com/xnimorz/use-debounce/blob/master/test/useDebounce.test.tsx#L276-L302 unit test for examples. 352 | 353 | ## 3.1.0 354 | 355 | - Now package includes only nessesary files. Thanks to [@vkrol](https://github.com/vkrol) 356 | - Added optional `equalityFn` to `options` object for `useDebounce` so that you can provide a custom equality function to the hook. Thanks to [@seruco](https://github.com/seruco) 357 | 358 | ## 3.0.1 359 | 360 | - Added missed `esm` directory (thanks for reporting [@FredyC](https://github.com/FredyC)) 361 | - Fixed import name (thanks for PR [@neoromantic](https://github.com/neoromantic)) 362 | - Updated `eslint-utils` lib version due to security issue 363 | 364 | ## 3.0.0 365 | 366 | - **breaking change** now, `cache` file renamed to `useDebounce` and `callback` file renamed to `useDebouncedCallback`. 367 | If you used to import file by its path: 368 | 369 | ```js 370 | import useDebounce from 'use-debounce/lib/cache'; 371 | import useDebouncedCallback from 'use-debounce/lib/callback'; 372 | ``` 373 | 374 | it should be renamed to 375 | 376 | ```js 377 | import useDebounce from 'use-debounce/lib/useDebounce'; 378 | import useDebouncedCallback from 'use-debounce/lib/useDebouncedCallback'; 379 | ``` 380 | 381 | It helps us to keep more descriptive names. Thanks to [@vkrol](https://github.com/vkrol) 382 | https://github.com/xnimorz/use-debounce/pull/33 383 | 384 | - **breaking change** now, `useDebouncedCallback` executes the latest callback, which was sent to the hook (thanks for the report [@alexandr-bbm](https://github.com/alexandr-bbm) https://github.com/xnimorz/use-debounce/issues/35) 385 | https://github.com/xnimorz/use-debounce/commit/eca14cc25b1f14bdd337a555127fd98c54ab7a5c 386 | 387 | - code shipped in ESM format. Thanks to [@vkrol](https://github.com/vkrol) 388 | https://github.com/xnimorz/use-debounce/pull/34 389 | 390 | ## 2.2.1 391 | 392 | — Added `types` field in package.json. Thanks to [@nmussy](https://github.com/nmussy) 393 | 394 | ## 2.2.0 395 | 396 | - Added leading calls param https://github.com/xnimorz/use-debounce#leading-calls thanks to [@Pringels](https://github.com/Pringels) 397 | - Updated dev-dependencies 398 | 399 | ## 2.1.0 400 | 401 | - Rewrite to typescript 402 | 403 | ## 2.0.1 404 | 405 | - Fix the issue https://github.com/xnimorz/use-debounce/issues/23. Thanks to [@anilanar](https://github.com/anilanar) for reporting it. 406 | - Add eslint to the project 407 | 408 | ## 2.0.0 409 | 410 | - **breaking changes** now, `useDebouncedCallback` doesn't have `deps` argument. If you want to cache your callback it's better to use: 411 | 412 | ```js 413 | const myCallback = useDebouncedCallback( 414 | useCallback(() => { 415 | /* do some stuff */ 416 | }, [value]), 417 | 500 418 | ); 419 | ``` 420 | 421 | - added `size-limit` to the project. 422 | - Reduce size of the library from 705 bytes to 352 bytes (50%) 423 | 424 | ## 1.1.3 425 | 426 | - remove `react-dom` from peerDependencies (as you can use this library with react native). 427 | 428 | ## 1.1.2 429 | 430 | - `useCallback` now memoize returned callback 431 | 432 | ## 1.1.0 433 | 434 | - add `callPending` callback to `useDebouncedCallback` method. It allows to call the callback manually if it hasn't fired yet. This method is handy to use when the user takes an action that would cause the component to unmount, but you need to execute the callback. 435 | 436 | ```javascript 437 | import React, { useState, useCallback } from 'react'; 438 | import useDebouncedCallback from 'use-debounce/lib/callback'; 439 | 440 | function InputWhichFetchesSomeData({ defaultValue, asyncFetchData }) { 441 | const [debouncedFunction, cancel, callPending] = useDebouncedCallback( 442 | (value) => { 443 | asyncFetchData; 444 | }, 445 | 500, 446 | [], 447 | { maxWait: 2000 } 448 | ); 449 | 450 | // When the component goes to be unmounted, we will fetch data if the input has changed. 451 | useEffect( 452 | () => () => { 453 | callPending(); 454 | }, 455 | [] 456 | ); 457 | 458 | return ( 459 | debouncedFunction(e.target.value)} 462 | /> 463 | ); 464 | } 465 | ``` 466 | 467 | More examples are available here: https://github.com/xnimorz/use-debounce/commit/989d6c0efb4eef080ed78330233186d7b0c249e3#diff-c7e0cfdec8acc174d3301ff43b986264R196 468 | 469 | ## 1.0.0 470 | 471 | The example with all features you can see here: https://codesandbox.io/s/4wvmp1xlw4 472 | 473 | - add maxWait option. The maximum time func is allowed to be delayed before it's invoked: 474 | 475 | ```javascript 476 | import { useDebounce, useDebouncedCallback } from 'use-debounce'; 477 | 478 | ... 479 | const debouncedValue = useDebounce(value, 300, {maxWait: 1000}); 480 | const debouncedCallback = useDebouncedCallback(() => {...}, 300, [], {maxWait: 1000}); 481 | ``` 482 | 483 | - add cancel callback (thanks to [@thibaultboursier](https://github.com/thibaultboursier) for contributing). Cancel callback removes func from the queue (even maxWait): 484 | 485 | ```javascript 486 | import { useDebounce, useDebouncedCallback } from 'use-debounce'; 487 | 488 | ... 489 | const [ debouncedValue, cancelValueDebouncingCycle ] = useDebounce(value, 1000); 490 | const [ debouncedCallback, cancelCallback ] = useDebouncedCallback(() => {...}, 1000); 491 | ``` 492 | 493 | - [BREAKING] change the contact of use-debounce callback and value hooks: 494 | 495 | **Old:** 496 | 497 | ```javascript 498 | import { useDebounce, useDebouncedCallback } from 'use-debounce'; 499 | 500 | ... 501 | const debouncedValue = useDebounce(value, 1000); 502 | const debouncedCallback = useDebouncedCallback(() => {...}, 1000); 503 | ``` 504 | 505 | **New:** 506 | 507 | ```javascript 508 | import { useDebounce, useDebouncedCallback } from 'use-debounce'; 509 | 510 | ... 511 | const [ debouncedValue, cancelValueDebouncingCycle ] = useDebounce(value, 1000); 512 | const [ debouncedCallback, cancelCallback ] = useDebouncedCallback(() => {...}, 1000); 513 | ``` 514 | 515 | You still can use only value and callback: 516 | 517 | ```javascript 518 | import { useDebounce, useDebouncedCallback } from 'use-debounce'; 519 | 520 | ... 521 | const [ debouncedValue ] = useDebounce(value, 1000); 522 | const [ debouncedCallback ] = useDebouncedCallback(() => {...}, 1000); 523 | ``` 524 | 525 | ## 0.0.x 526 | 527 | - add use-debounce callback and use-debounce value. First one is useful for debouncing callbacks e.g. event handlers, second one is handy for debouncing a value such as search fields etc. 528 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Nikita Mostovoy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | use-debounce 4 | 5 |
6 | 7 |
8 | 9 | npm 10 | 11 | 12 | downloads 13 | 14 | 15 | tree-shakeable 16 | 17 | 18 | types included 19 | 20 |
21 | 22 | # useDebounce, useDebouncedCallback & useThrottledCallback 23 | 24 | React libraries for debouncing without tears! 25 | 26 | - Small size < 1 Kb 27 | - Compatible with underscore / lodash impl — learn once, use everywhere 28 | - Server-rendering friendly! 29 | 30 | ## Features 31 | 32 | - [classic debounced callback](#debounced-callbacks) 33 | - [**value** debouncing](#simple-values-debouncing) 34 | - [cancel, maxWait and memoization](#advanced-usage) 35 | 36 | ## Install 37 | 38 | ```sh 39 | yarn add use-debounce 40 | # or 41 | npm i use-debounce --save 42 | ``` 43 | 44 | ## Copy paste guidance: 45 | 46 | ### use-debounce 47 | 48 | Simple usage: https://codesandbox.io/s/kx75xzyrq7 49 | 50 | Debounce HTTP request: https://codesandbox.io/s/rr40wnropq 51 | 52 | Debounce HTTP request with `leading` param: https://codesandbox.io/s/cache-example-with-areas-and-leading-param-119r3i 53 | 54 | ### use-debounce callback 55 | 56 | Simple usage: https://codesandbox.io/s/x0jvqrwyq 57 | 58 | Combining with native event listeners: https://codesandbox.io/s/32yqlyo815 59 | 60 | Cancelling, maxWait and memoization: https://codesandbox.io/s/4wvmp1xlw4 61 | 62 | HTTP requests: https://codesandbox.io/s/use-debounce-callback-http-y1h3m6 63 | 64 | ## Changelog 65 | 66 | https://github.com/xnimorz/use-debounce/blob/master/CHANGELOG.md 67 | 68 | ## Simple values debouncing 69 | 70 | According to https://twitter.com/dan_abramov/status/1060729512227467264 71 | WebArchive link: https://web.archive.org/web/20210828073432/https://twitter.com/dan_abramov/status/1060729512227467264 72 | 73 | ```javascript 74 | import React, { useState } from 'react'; 75 | import { useDebounce } from 'use-debounce'; 76 | 77 | export default function Input() { 78 | const [text, setText] = useState('Hello'); 79 | const [value] = useDebounce(text, 1000); 80 | 81 | return ( 82 |
83 | { 86 | setText(e.target.value); 87 | }} 88 | /> 89 |

Actual value: {text}

90 |

Debounce value: {value}

91 |
92 | ); 93 | } 94 | ``` 95 | 96 | This hook compares prev and next value using shallow equal. It means, setting an object `{}` will trigger debounce timer. If you have to compare objects (https://github.com/xnimorz/use-debounce/issues/27#issuecomment-496828063), you can use `useDebouncedCallback`, that is explained below: 97 | 98 | ## Debounced callbacks 99 | 100 | Besides `useDebounce` for values you can debounce callbacks, that is the more commonly understood kind of debouncing. 101 | Example with Input (and react callbacks): https://codesandbox.io/s/x0jvqrwyq 102 | 103 | ```js 104 | import { useDebouncedCallback } from 'use-debounce'; 105 | 106 | function Input({ defaultValue }) { 107 | const [value, setValue] = useState(defaultValue); 108 | // Debounce callback 109 | const debounced = useDebouncedCallback( 110 | // function 111 | (value) => { 112 | setValue(value); 113 | }, 114 | // delay in ms 115 | 1000 116 | ); 117 | 118 | // you should use `e => debounced(e.target.value)` as react works with synthetic events 119 | return ( 120 |
121 | debounced(e.target.value)} 124 | /> 125 |

Debounced value: {value}

126 |
127 | ); 128 | } 129 | ``` 130 | 131 | Example with Scroll (and native event listeners): https://codesandbox.io/s/32yqlyo815 132 | 133 | ```js 134 | function ScrolledComponent() { 135 | // just a counter to show, that there are no any unnessesary updates 136 | const updatedCount = useRef(0); 137 | updatedCount.current++; 138 | 139 | const [position, setPosition] = useState(window.pageYOffset); 140 | 141 | // Debounce callback 142 | const debounced = useDebouncedCallback( 143 | // function 144 | () => { 145 | setPosition(window.pageYOffset); 146 | }, 147 | // delay in ms 148 | 800 149 | ); 150 | 151 | useEffect(() => { 152 | const unsubscribe = subscribe(window, 'scroll', debounced); 153 | return () => { 154 | unsubscribe(); 155 | }; 156 | }, []); 157 | 158 | return ( 159 |
160 |
161 |

Debounced top position: {position}

162 |

Component rerendered {updatedCount.current} times

163 |
164 |
165 | ); 166 | } 167 | ``` 168 | 169 | ### Returned value from `debounced()` 170 | 171 | Subsequent calls to the debounced function `debounced` return the result of the last func invocation. 172 | Note, that if there are no previous invocations it's mean you will get undefined. You should check it in your code properly. 173 | 174 | Example: 175 | 176 | ```javascript 177 | it('Subsequent calls to the debounced function `debounced` return the result of the last func invocation.', () => { 178 | const callback = jest.fn(() => 42); 179 | 180 | let callbackCache; 181 | function Component() { 182 | const debounced = useDebouncedCallback(callback, 1000); 183 | callbackCache = debounced; 184 | return null; 185 | } 186 | Enzyme.mount(); 187 | 188 | const result = callbackCache(); 189 | expect(callback.mock.calls.length).toBe(0); 190 | expect(result).toBeUndefined(); 191 | 192 | act(() => { 193 | jest.runAllTimers(); 194 | }); 195 | expect(callback.mock.calls.length).toBe(1); 196 | const subsequentResult = callbackCache(); 197 | 198 | expect(callback.mock.calls.length).toBe(1); 199 | expect(subsequentResult).toBe(42); 200 | }); 201 | ``` 202 | 203 | ### Advanced usage 204 | 205 | #### Cancel, maxWait and memoization 206 | 207 | 1. Both `useDebounce` and `useDebouncedCallback` works with `maxWait` option. This params describes the maximum time func is allowed to be delayed before it's invoked. 208 | 2. You can cancel debounce cycle, by calling `cancel` callback 209 | 210 | The full example you can see here https://codesandbox.io/s/4wvmp1xlw4 211 | 212 | ```javascript 213 | import React, { useState } from 'react'; 214 | import ReactDOM from 'react-dom'; 215 | import { useDebouncedCallback } from 'use-debounce'; 216 | 217 | function Input({ defaultValue }) { 218 | const [value, setValue] = useState(defaultValue); 219 | const debounced = useDebouncedCallback( 220 | (value) => { 221 | setValue(value); 222 | }, 223 | 500, 224 | // The maximum time func is allowed to be delayed before it's invoked: 225 | { maxWait: 2000 } 226 | ); 227 | 228 | // you should use `e => debounced(e.target.value)` as react works with synthetic events 229 | return ( 230 |
231 | debounced(e.target.value)} 234 | /> 235 |

Debounced value: {value}

236 | 237 |
238 | ); 239 | } 240 | 241 | const rootElement = document.getElementById('root'); 242 | ReactDOM.render(, rootElement); 243 | ``` 244 | 245 | The same API is available for `useDebounce` calls: 246 | ```js 247 | const [value, {cancel, isPending, flush}] = useDebounce(valueToDebounce); 248 | ... 249 | cancel() // cancels pending debounce request 250 | isPending() // returns if there is a pending debouncing request 251 | flush() // immediately flushes pending request 252 | ``` 253 | 254 | 255 | #### Flush method 256 | 257 | `useDebouncedCallback` has `flush` method. It allows to call the callback manually if it hasn't fired yet. This method is handy to use when the user takes an action that would cause the component to unmount, but you need to execute the callback. 258 | 259 | ```javascript 260 | import React, { useState } from 'react'; 261 | import { useDebouncedCallback } from 'use-debounce'; 262 | 263 | function InputWhichFetchesSomeData({ defaultValue, asyncFetchData }) { 264 | const debounced = useDebouncedCallback( 265 | (value) => { 266 | asyncFetchData; 267 | }, 268 | 500, 269 | { maxWait: 2000 } 270 | ); 271 | 272 | // When the component goes to be unmounted, we will fetch data if the input has changed. 273 | useEffect( 274 | () => () => { 275 | debounced.flush(); 276 | }, 277 | [debounced] 278 | ); 279 | 280 | return ( 281 | debounced(e.target.value)} 284 | /> 285 | ); 286 | } 287 | ``` 288 | 289 | #### isPending method 290 | 291 | `isPending` method shows whether component has pending callbacks. Works for both `useDebounce` and `useDebouncedCallback`: 292 | 293 | ```javascript 294 | import React, { useCallback } from 'react'; 295 | 296 | function Component({ text }) { 297 | const debounced = useDebouncedCallback( 298 | useCallback(() => {}, []), 299 | 500 300 | ); 301 | 302 | expect(debounced.isPending()).toBeFalsy(); 303 | debounced(); 304 | expect(debounced.isPending()).toBeTruthy(); 305 | debounced.flush(); 306 | expect(debounced.isPending()).toBeFalsy(); 307 | 308 | return {text}; 309 | } 310 | ``` 311 | 312 | #### leading/trailing calls 313 | 314 | Both `useDebounce` and `useDebouncedCallback` work with the `leading` and `trailing` options. `leading` param will execute the function once immediately when called. Subsequent calls will be debounced until the timeout expires. `trailing` option controls whenever to call the callback after timeout again. 315 | 316 | For more information on how leading debounce calls work see: https://lodash.com/docs/#debounce 317 | 318 | ```javascript 319 | import React, { useState } from 'react'; 320 | import { useDebounce } from 'use-debounce'; 321 | 322 | export default function Input() { 323 | const [text, setText] = useState('Hello'); 324 | const [value] = useDebounce(text, 1000, { leading: true }); 325 | 326 | // value is updated immediately when text changes the first time, 327 | // but all subsequent changes are debounced. 328 | return ( 329 |
330 | { 333 | setText(e.target.value); 334 | }} 335 | /> 336 |

Actual value: {text}

337 |

Debounce value: {value}

338 |
339 | ); 340 | } 341 | ``` 342 | 343 | #### Options: 344 | 345 | You can provide additional options as a third argument to both `useDebounce` and `useDebouncedCallback`: 346 | 347 | | option | default | Description | Example | 348 | | ---------- | ----------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | 349 | | maxWait | - | Describes the maximum time func is allowed to be delayed before it's invoked | https://github.com/xnimorz/use-debounce#cancel-maxwait-and-memoization | 350 | | leading | - | This param will execute the function once immediately when called. Subsequent calls will be debounced until the timeout expires. | https://github.com/xnimorz/use-debounce#leading-calls | 351 | | trailing | true | This param executes the function after timeout. | https://github.com/xnimorz/use-debounce#leading-calls | 352 | | equalityFn | (prev, next) => prev === next | [useDebounce ONLY] Comparator function which shows if timeout should be started | | 353 | 354 | ## useThrottledCallback 355 | 356 | You are able to use throttled callback with this library also (starting 5.2.0 version). 357 | For this purpose use: 358 | 359 | ``` 360 | import useThrottledCallback from 'use-debounce/useThrottledCallback'; 361 | ``` 362 | 363 | or 364 | 365 | ``` 366 | import { useThrottledCallback } from 'use-debounce'; 367 | ``` 368 | 369 | Several examples: 370 | 371 | 1. Avoid excessively updating the position while scrolling. 372 | 373 | ```js 374 | const scrollHandler = useThrottledCallback(updatePosition, 100); 375 | window.addEventListener('scroll', scrollHandler); 376 | ``` 377 | 378 | 2. Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes. 379 | ```js 380 | const throttled = useThrottledCallback(renewToken, 300000, { 'trailing': false }) 381 | 382 | ``` 383 | 384 | All the params for `useThrottledCallback` are the same as for `useDebouncedCallback` except `maxWait` option. As it's not needed for throttle callbacks. 385 | 386 | # Special thanks: 387 | 388 | [@tryggvigy](https://github.com/tryggvigy) — for managing lots of new features of the library like trailing and leading params, throttle callback, etc; 389 | 390 | [@omgovich](https://github.com/omgovich) — for reducing bundle size. 391 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest').JestConfigWithTsJest} */ 2 | module.exports = { 3 | preset: 'ts-jest', 4 | testEnvironment: 'jsdom', 5 | roots: ['/test'], 6 | setupFilesAfterEnv: ["/setupTests.ts"], 7 | }; -------------------------------------------------------------------------------- /logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xnimorz/use-debounce/c10b7e8b9653aab00e20a12aac2fe6f4c068dfee/logo.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "use-debounce", 3 | "version": "10.0.4", 4 | "description": "Debounce hook for react", 5 | "source": "src/index.ts", 6 | "main": "dist/index.js", 7 | "module": "dist/index.module.js", 8 | "esmodule": "dist/index.mjs", 9 | "umd:main": "dist/index.umd.js", 10 | "exports": { 11 | ".": { 12 | "types": "./dist/index.d.ts", 13 | "module": "./dist/index.module.js", 14 | "import": "./dist/index.mjs", 15 | "require": "./dist/index.js" 16 | }, 17 | "./package.json": "./package.json" 18 | }, 19 | "sideEffects": false, 20 | "scripts": { 21 | "jest": "jest", 22 | "size": "npm run build-only && size-limit", 23 | "test": "jest && eslint \"src/**.ts\"", 24 | "build-only": "rm -rf ./dist/*; microbundle build --entry src/index.ts --name use-debounce --tsconfig tsconfig.json", 25 | "build": "npm run test && npm run build-only && size-limit", 26 | "prepublishOnly": "npm run build" 27 | }, 28 | "engines": { 29 | "node": ">= 16.0.0" 30 | }, 31 | "repository": { 32 | "type": "git", 33 | "url": "git+ssh://git@github.com/xnimorz/use-debounce.git" 34 | }, 35 | "keywords": [ 36 | "debounce", 37 | "react-hook", 38 | "react" 39 | ], 40 | "author": "Nik (nik@xnim.me)", 41 | "license": "MIT", 42 | "bugs": { 43 | "url": "https://github.com/xnimorz/use-debounce/issues" 44 | }, 45 | "files": [ 46 | "dist" 47 | ], 48 | "peerDependencies": { 49 | "react": "*" 50 | }, 51 | "homepage": "https://github.com/xnimorz/use-debounce#readme", 52 | "devDependencies": { 53 | "@size-limit/preset-small-lib": "^10.0.1", 54 | "@testing-library/jest-dom": "^6.1.4", 55 | "@testing-library/react": "^14.0.0", 56 | "@testing-library/user-event": "^14.5.1", 57 | "@types/jest": "^29.5.7", 58 | "@types/node": "^20.8.9", 59 | "@types/react": "^18.2.33", 60 | "@types/react-dom": "^18.2.14", 61 | "@typescript-eslint/eslint-plugin": "^6.9.1", 62 | "@typescript-eslint/parser": "^6.9.1", 63 | "eslint": "^8.52.0", 64 | "eslint-config-prettier": "^9.0.0", 65 | "eslint-config-standard": "^17.1.0", 66 | "eslint-plugin-import": "^2.29.0", 67 | "eslint-plugin-node": "^11.1.0", 68 | "eslint-plugin-prettier": "^5.0.1", 69 | "eslint-plugin-promise": "^6.1.1", 70 | "eslint-plugin-react-hooks": "^4.6.0", 71 | "eslint-plugin-standard": "^4.1.0", 72 | "jest": "^29.7.0", 73 | "jest-environment-jsdom": "^29.7.0", 74 | "microbundle": "^0.15.1", 75 | "prettier": "^3.0.3", 76 | "react": "18.2.0", 77 | "react-dom": "18.2.0", 78 | "size-limit": "^10.0.1", 79 | "ts-jest": "^29.1.1", 80 | "typescript": "^5.2.2" 81 | }, 82 | "resolutions": { 83 | "kind-of": "6.0.3" 84 | }, 85 | "size-limit": [ 86 | { 87 | "path": "dist/index.js", 88 | "limit": "1.2 KB" 89 | }, 90 | { 91 | "path": "dist/index.module.js", 92 | "limit": "1.2 KB" 93 | }, 94 | { 95 | "path": "dist/index.umd.js", 96 | "limit": "1.2 KB" 97 | }, 98 | { 99 | "path": "dist/index.js", 100 | "import": "{ useDebounce }", 101 | "limit": "1.2 KB" 102 | }, 103 | { 104 | "path": "dist/index.js", 105 | "import": "{ useDebouncedCallback }", 106 | "limit": "1.2 KB" 107 | }, 108 | { 109 | "path": "dist/index.js", 110 | "import": "{ useThrottledCallback }", 111 | "limit": "1.2 KB" 112 | } 113 | ] 114 | } 115 | -------------------------------------------------------------------------------- /setupTests.ts: -------------------------------------------------------------------------------- 1 | import '@testing-library/jest-dom'; -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import useDebounce from './useDebounce'; 2 | import useDebouncedCallback from './useDebouncedCallback'; 3 | import type { 4 | CallOptions, 5 | ControlFunctions, 6 | DebouncedState, 7 | Options, 8 | } from './useDebouncedCallback'; 9 | import useThrottledCallback from './useThrottledCallback'; 10 | 11 | export { useDebounce, useDebouncedCallback, useThrottledCallback }; 12 | 13 | export { CallOptions, ControlFunctions, DebouncedState, Options }; 14 | -------------------------------------------------------------------------------- /src/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useCallback, useRef, useState } from 'react'; 2 | import useDebouncedCallback, { DebouncedState } from './useDebouncedCallback'; 3 | 4 | function valueEquality(left: T, right: T): boolean { 5 | return left === right; 6 | } 7 | 8 | export default function useDebounce( 9 | value: T, 10 | delay: number, 11 | options?: { 12 | maxWait?: number; 13 | leading?: boolean; 14 | trailing?: boolean; 15 | equalityFn?: (left: T, right: T) => boolean; 16 | } 17 | ): [T, DebouncedState<(value: T) => void>] { 18 | const eq = (options && options.equalityFn) || valueEquality; 19 | 20 | const activeValue = useRef(value); 21 | const [, forceUpdate] = useState({}); 22 | const debounced = useDebouncedCallback( 23 | useCallback( 24 | (value: T) => { 25 | activeValue.current = value; 26 | forceUpdate({}); 27 | }, 28 | [forceUpdate] 29 | ), 30 | delay, 31 | options 32 | ); 33 | const previousValue = useRef(value); 34 | 35 | if (!eq(previousValue.current, value)) { 36 | debounced(value); 37 | previousValue.current = value; 38 | } 39 | 40 | return [activeValue.current as T, debounced]; 41 | } 42 | -------------------------------------------------------------------------------- /src/useDebouncedCallback.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useMemo } from 'react'; 2 | 3 | export interface CallOptions { 4 | /** 5 | * Controls if the function should be invoked on the leading edge of the timeout. 6 | */ 7 | leading?: boolean; 8 | /** 9 | * Controls if the function should be invoked on the trailing edge of the timeout. 10 | */ 11 | trailing?: boolean; 12 | } 13 | 14 | export interface Options extends CallOptions { 15 | /** 16 | * The maximum time the given function is allowed to be delayed before it's invoked. 17 | */ 18 | maxWait?: number; 19 | /** 20 | * If the setting is set to true, all debouncing and timers will happen on the server side as well 21 | */ 22 | debounceOnServer?: boolean; 23 | } 24 | 25 | export interface ControlFunctions { 26 | /** 27 | * Cancel pending function invocations 28 | */ 29 | cancel: () => void; 30 | /** 31 | * Immediately invoke pending function invocations 32 | */ 33 | flush: () => ReturnT | undefined; 34 | /** 35 | * Returns `true` if there are any pending function invocations 36 | */ 37 | isPending: () => boolean; 38 | } 39 | 40 | /** 41 | * Subsequent calls to the debounced function return the result of the last func invocation. 42 | * Note, that if there are no previous invocations you will get undefined. You should check it in your code properly. 43 | */ 44 | export interface DebouncedState ReturnType> 45 | extends ControlFunctions> { 46 | (...args: Parameters): ReturnType | undefined; 47 | } 48 | 49 | /** 50 | * Creates a debounced function that delays invoking `func` until after `wait` 51 | * milliseconds have elapsed since the last time the debounced function was 52 | * invoked, or until the next browser frame is drawn. 53 | * 54 | * The debounced function comes with a `cancel` method to cancel delayed `func` 55 | * invocations and a `flush` method to immediately invoke them. 56 | * 57 | * Provide `options` to indicate whether `func` should be invoked on the leading 58 | * and/or trailing edge of the `wait` timeout. The `func` is invoked with the 59 | * last arguments provided to the debounced function. 60 | * 61 | * Subsequent calls to the debounced function return the result of the last 62 | * `func` invocation. 63 | * 64 | * **Note:** If `leading` and `trailing` options are `true`, `func` is 65 | * invoked on the trailing edge of the timeout only if the debounced function 66 | * is invoked more than once during the `wait` timeout. 67 | * 68 | * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred 69 | * until the next tick, similar to `setTimeout` with a timeout of `0`. 70 | * 71 | * If `wait` is omitted in an environment with `requestAnimationFrame`, `func` 72 | * invocation will be deferred until the next frame is drawn (typically about 73 | * 16ms). 74 | * 75 | * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 76 | * for details over the differences between `debounce` and `throttle`. 77 | * 78 | * @category Function 79 | * @param {Function} func The function to debounce. 80 | * @param {number} [wait=0] 81 | * The number of milliseconds to delay; if omitted, `requestAnimationFrame` is 82 | * used (if available, otherwise it will be setTimeout(...,0)). 83 | * @param {Object} [options={}] The options object. 84 | * Controls if `func` should be invoked on the leading edge of the timeout. 85 | * @param {boolean} [options.leading=false] 86 | * The maximum time `func` is allowed to be delayed before it's invoked. 87 | * @param {number} [options.maxWait] 88 | * Controls if `func` should be invoked the trailing edge of the timeout. 89 | * @param {boolean} [options.trailing=true] 90 | * @returns {Function} Returns the new debounced function. 91 | * @example 92 | * 93 | * // Avoid costly calculations while the window size is in flux. 94 | * const resizeHandler = useDebouncedCallback(calculateLayout, 150); 95 | * window.addEventListener('resize', resizeHandler) 96 | * 97 | * // Invoke `sendMail` when clicked, debouncing subsequent calls. 98 | * const clickHandler = useDebouncedCallback(sendMail, 300, { 99 | * leading: true, 100 | * trailing: false, 101 | * }) 102 | * 103 | * 104 | * // Ensure `batchLog` is invoked once after 1 second of debounced calls. 105 | * const debounced = useDebouncedCallback(batchLog, 250, { 'maxWait': 1000 }) 106 | * const source = new EventSource('/stream') 107 | * source.addEventListener('message', debounced) 108 | * 109 | * // Cancel the trailing debounced invocation. 110 | * window.addEventListener('popstate', debounced.cancel) 111 | * 112 | * // Check for pending invocations. 113 | * const status = debounced.isPending() ? "Pending..." : "Ready" 114 | */ 115 | export default function useDebouncedCallback< 116 | T extends (...args: any) => ReturnType, 117 | >(func: T, wait?: number, options?: Options): DebouncedState { 118 | const lastCallTime = useRef(null); 119 | const lastInvokeTime = useRef(0); 120 | const timerId = useRef(null); 121 | const lastArgs = useRef([]); 122 | const lastThis = useRef(); 123 | const result = useRef>(); 124 | const funcRef = useRef(func); 125 | const mounted = useRef(true); 126 | // Always keep the latest version of debounce callback, with no wait time. 127 | funcRef.current = func; 128 | 129 | const isClientSide = typeof window !== 'undefined'; 130 | // Bypass `requestAnimationFrame` by explicitly setting `wait=0`. 131 | const useRAF = !wait && wait !== 0 && isClientSide; 132 | 133 | if (typeof func !== 'function') { 134 | throw new TypeError('Expected a function'); 135 | } 136 | 137 | wait = +wait || 0; 138 | options = options || {}; 139 | 140 | const leading = !!options.leading; 141 | const trailing = 'trailing' in options ? !!options.trailing : true; // `true` by default 142 | const maxing = 'maxWait' in options; 143 | const debounceOnServer = 144 | 'debounceOnServer' in options ? !!options.debounceOnServer : false; // `false` by default 145 | const maxWait = maxing ? Math.max(+options.maxWait || 0, wait) : null; 146 | 147 | useEffect(() => { 148 | mounted.current = true; 149 | return () => { 150 | mounted.current = false; 151 | }; 152 | }, []); 153 | 154 | // You may have a question, why we have so many code under the useMemo definition. 155 | // 156 | // This was made as we want to escape from useCallback hell and 157 | // not to initialize a number of functions each time useDebouncedCallback is called. 158 | // 159 | // It means that we have less garbage for our GC calls which improves performance. 160 | // Also, it makes this library smaller. 161 | // 162 | // And the last reason, that the code without lots of useCallback with deps is easier to read. 163 | // You have only one place for that. 164 | const debounced = useMemo(() => { 165 | const invokeFunc = (time: number) => { 166 | const args = lastArgs.current; 167 | const thisArg = lastThis.current; 168 | 169 | lastArgs.current = lastThis.current = null; 170 | lastInvokeTime.current = time; 171 | return (result.current = funcRef.current.apply(thisArg, args)); 172 | }; 173 | 174 | const startTimer = (pendingFunc: () => void, wait: number) => { 175 | if (useRAF) cancelAnimationFrame(timerId.current); 176 | timerId.current = useRAF 177 | ? requestAnimationFrame(pendingFunc) 178 | : setTimeout(pendingFunc, wait); 179 | }; 180 | 181 | const shouldInvoke = (time: number) => { 182 | if (!mounted.current) return false; 183 | 184 | const timeSinceLastCall = time - lastCallTime.current; 185 | const timeSinceLastInvoke = time - lastInvokeTime.current; 186 | 187 | // Either this is the first call, activity has stopped and we're at the 188 | // trailing edge, the system time has gone backwards and we're treating 189 | // it as the trailing edge, or we've hit the `maxWait` limit. 190 | return ( 191 | !lastCallTime.current || 192 | timeSinceLastCall >= wait || 193 | timeSinceLastCall < 0 || 194 | (maxing && timeSinceLastInvoke >= maxWait) 195 | ); 196 | }; 197 | 198 | const trailingEdge = (time: number) => { 199 | timerId.current = null; 200 | 201 | // Only invoke if we have `lastArgs` which means `func` has been 202 | // debounced at least once. 203 | if (trailing && lastArgs.current) { 204 | return invokeFunc(time); 205 | } 206 | lastArgs.current = lastThis.current = null; 207 | return result.current; 208 | }; 209 | 210 | const timerExpired = () => { 211 | const time = Date.now(); 212 | if (shouldInvoke(time)) { 213 | return trailingEdge(time); 214 | } 215 | // https://github.com/xnimorz/use-debounce/issues/97 216 | if (!mounted.current) { 217 | return; 218 | } 219 | // Remaining wait calculation 220 | const timeSinceLastCall = time - lastCallTime.current; 221 | const timeSinceLastInvoke = time - lastInvokeTime.current; 222 | const timeWaiting = wait - timeSinceLastCall; 223 | const remainingWait = maxing 224 | ? Math.min(timeWaiting, maxWait - timeSinceLastInvoke) 225 | : timeWaiting; 226 | 227 | // Restart the timer 228 | startTimer(timerExpired, remainingWait); 229 | }; 230 | 231 | const func: DebouncedState = (...args: Parameters): ReturnType => { 232 | if (!isClientSide && !debounceOnServer) { 233 | return; 234 | } 235 | const time = Date.now(); 236 | const isInvoking = shouldInvoke(time); 237 | 238 | lastArgs.current = args; 239 | lastThis.current = this; 240 | lastCallTime.current = time; 241 | 242 | if (isInvoking) { 243 | if (!timerId.current && mounted.current) { 244 | // Reset any `maxWait` timer. 245 | lastInvokeTime.current = lastCallTime.current; 246 | // Start the timer for the trailing edge. 247 | startTimer(timerExpired, wait); 248 | // Invoke the leading edge. 249 | return leading ? invokeFunc(lastCallTime.current) : result.current; 250 | } 251 | if (maxing) { 252 | // Handle invocations in a tight loop. 253 | startTimer(timerExpired, wait); 254 | return invokeFunc(lastCallTime.current); 255 | } 256 | } 257 | if (!timerId.current) { 258 | startTimer(timerExpired, wait); 259 | } 260 | return result.current; 261 | }; 262 | 263 | func.cancel = () => { 264 | if (timerId.current) { 265 | useRAF 266 | ? cancelAnimationFrame(timerId.current) 267 | : clearTimeout(timerId.current); 268 | } 269 | lastInvokeTime.current = 0; 270 | lastArgs.current = 271 | lastCallTime.current = 272 | lastThis.current = 273 | timerId.current = 274 | null; 275 | }; 276 | 277 | func.isPending = () => { 278 | return !!timerId.current; 279 | }; 280 | 281 | func.flush = () => { 282 | return !timerId.current ? result.current : trailingEdge(Date.now()); 283 | }; 284 | 285 | return func; 286 | }, [ 287 | leading, 288 | maxing, 289 | wait, 290 | maxWait, 291 | trailing, 292 | useRAF, 293 | isClientSide, 294 | debounceOnServer, 295 | ]); 296 | 297 | return debounced; 298 | } 299 | -------------------------------------------------------------------------------- /src/useThrottledCallback.ts: -------------------------------------------------------------------------------- 1 | import useDebouncedCallback, { 2 | CallOptions, 3 | DebouncedState, 4 | } from './useDebouncedCallback'; 5 | 6 | /** 7 | * Creates a throttled function that only invokes `func` at most once per 8 | * every `wait` milliseconds (or once per browser frame). 9 | * 10 | * The throttled function comes with a `cancel` method to cancel delayed `func` 11 | * invocations and a `flush` method to immediately invoke them. 12 | * 13 | * Provide `options` to indicate whether `func` should be invoked on the leading 14 | * and/or trailing edge of the `wait` timeout. The `func` is invoked with the 15 | * last arguments provided to the throttled function. 16 | * 17 | * Subsequent calls to the throttled function return the result of the last 18 | * `func` invocation. 19 | * 20 | * **Note:** If `leading` and `trailing` options are `true`, `func` is 21 | * invoked on the trailing edge of the timeout only if the throttled function 22 | * is invoked more than once during the `wait` timeout. 23 | * 24 | * If `wait` is `0` and `leading` is `false`, `func` invocation is deferred 25 | * until the next tick, similar to `setTimeout` with a timeout of `0`. 26 | * 27 | * If `wait` is omitted in an environment with `requestAnimationFrame`, `func` 28 | * invocation will be deferred until the next frame is drawn (typically about 29 | * 16ms). 30 | * 31 | * See [David Corbacho's article](https://css-tricks.com/debouncing-throttling-explained-examples/) 32 | * for details over the differences between `throttle` and `debounce`. 33 | * 34 | * @category Function 35 | * @param {Function} func The function to throttle. 36 | * @param {number} [wait=0] 37 | * The number of milliseconds to throttle invocations to; if omitted, 38 | * `requestAnimationFrame` is used (if available, otherwise it will be setTimeout(...,0)). 39 | * @param {Object} [options={}] The options object. 40 | * @param {boolean} [options.leading=true] 41 | * Specify invoking on the leading edge of the timeout. 42 | * @param {boolean} [options.trailing=true] 43 | * Specify invoking on the trailing edge of the timeout. 44 | * @returns {Function} Returns the new throttled function. 45 | * @example 46 | * 47 | * // Avoid excessively updating the position while scrolling. 48 | * const scrollHandler = useThrottledCallback(updatePosition, 100) 49 | * window.addEventListener('scroll', scrollHandler) 50 | * 51 | * // Invoke `renewToken` when the click event is fired, but not more than once every 5 minutes. 52 | * const throttled = useThrottledCallback(renewToken, 300000, { 'trailing': false }) 53 | * 54 | * 55 | * // Cancel the trailing throttled invocation. 56 | * window.addEventListener('popstate', throttled.cancel); 57 | */ 58 | export default function useThrottledCallback< 59 | T extends (...args: any) => ReturnType, 60 | >( 61 | func: T, 62 | wait: number, 63 | { leading = true, trailing = true }: CallOptions = {} 64 | ): DebouncedState { 65 | return useDebouncedCallback(func, wait, { 66 | maxWait: wait, 67 | leading, 68 | trailing, 69 | }); 70 | } 71 | -------------------------------------------------------------------------------- /test/useDebounce.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act } from '@testing-library/react'; 2 | import * as React from 'react'; 3 | import useDebounce from '../src/useDebounce'; 4 | import { describe, it, expect, jest, beforeEach } from '@jest/globals'; 5 | 6 | describe('useDebounce', () => { 7 | beforeEach(() => { 8 | jest.useFakeTimers(); 9 | }); 10 | it('put initialized value in first render', () => { 11 | function Component() { 12 | const [value] = useDebounce('Hello world', 1000); 13 | return
{value}
; 14 | } 15 | render(); 16 | // @ts-ignore 17 | expect(screen.getByRole('test')).toHaveTextContent('Hello world'); 18 | }); 19 | 20 | it('will update value when timer is called', () => { 21 | function Component({ text }) { 22 | const [value] = useDebounce(text, 1000); 23 | return
{value}
; 24 | } 25 | const { rerender } = render(); 26 | 27 | // @ts-ignore 28 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 29 | 30 | rerender(); 31 | 32 | // @ts-ignore 33 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 34 | 35 | act(() => { 36 | jest.runAllTimers(); 37 | }); 38 | // after runAllTimer text should be updated 39 | // @ts-ignore 40 | expect(screen.getByRole('test')).toHaveTextContent('Hello world'); 41 | }); 42 | 43 | it('will update value immediately if leading is set to true', () => { 44 | function Component({ text }) { 45 | const [value] = useDebounce(text, 1000, { leading: true }); 46 | return
{value}
; 47 | } 48 | const tree = render(); 49 | 50 | // check inited value 51 | // @ts-ignore 52 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 53 | 54 | act(() => { 55 | tree.rerender(); 56 | }); 57 | 58 | // value should be set immediately by first leading call 59 | // @ts-ignore 60 | expect(screen.getByRole('test')).toHaveTextContent('Hello world'); 61 | 62 | act(() => { 63 | tree.rerender(); 64 | }); 65 | 66 | // timeout shouldn't have been called yet after leading call was executed 67 | // @ts-ignore 68 | expect(screen.getByRole('test')).toHaveTextContent('Hello world'); 69 | 70 | act(() => { 71 | jest.runAllTimers(); 72 | }); 73 | // final value should update as per last timeout 74 | // @ts-ignore 75 | expect(screen.getByRole('test')).toHaveTextContent('Hello again'); 76 | }); 77 | 78 | it('will cancel value when cancel method is called', () => { 79 | function Component({ text }) { 80 | const [value, fn] = useDebounce(text, 1000); 81 | setTimeout(fn.cancel, 500); 82 | return
{value}
; 83 | } 84 | const tree = render(); 85 | 86 | // @ts-ignore 87 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 88 | 89 | act(() => { 90 | tree.rerender(); 91 | }); 92 | // timeout shouldn't have called yet 93 | // @ts-ignore 94 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 95 | 96 | act(() => { 97 | jest.runAllTimers(); 98 | }); 99 | // after runAllTimer text should not be updated as debounce was cancelled 100 | // @ts-ignore 101 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 102 | }); 103 | 104 | it('should apply the latest value', () => { 105 | function Component({ text }) { 106 | const [value] = useDebounce(text, 1000); 107 | return
{value}
; 108 | } 109 | const tree = render(); 110 | 111 | // check inited value 112 | // @ts-ignore 113 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 114 | 115 | act(() => { 116 | // this value shouldn't be applied, as we'll set up another one 117 | tree.rerender(); 118 | }); 119 | // timeout shouldn't have called yet 120 | // @ts-ignore 121 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 122 | 123 | tree.rerender(); 124 | 125 | act(() => { 126 | jest.runAllTimers(); 127 | }); 128 | // after runAllTimer text should be updated 129 | // @ts-ignore 130 | expect(screen.getByRole('test')).toHaveTextContent('Right value'); 131 | }); 132 | 133 | it('should cancel maxWait callback', () => { 134 | function Component({ text }) { 135 | const [value, fn] = useDebounce(text, 500, { maxWait: 600 }); 136 | if (text === 'Right value') { 137 | fn.cancel(); 138 | } 139 | return
{value}
; 140 | } 141 | const tree = render(); 142 | 143 | // check inited value 144 | // @ts-ignore 145 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 146 | 147 | act(() => { 148 | // this value shouldn't be applied, as we'll set up another one 149 | tree.rerender(); 150 | }); 151 | 152 | act(() => { 153 | jest.advanceTimersByTime(400); 154 | }); 155 | 156 | // timeout shouldn't have called yet 157 | // @ts-ignore 158 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 159 | 160 | act(() => { 161 | tree.rerender(); 162 | }); 163 | 164 | act(() => { 165 | jest.advanceTimersByTime(400); 166 | }); 167 | 168 | // @ts-ignore 169 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 170 | }); 171 | 172 | it('should apply the latest value if maxWait timeout is called', () => { 173 | function Component({ text }) { 174 | const [value] = useDebounce(text, 500, { maxWait: 600 }); 175 | return
{value}
; 176 | } 177 | const tree = render(); 178 | 179 | // check inited value 180 | // @ts-ignore 181 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 182 | 183 | act(() => { 184 | // this value shouldn't be applied, as we'll set up another one 185 | tree.rerender(); 186 | }); 187 | 188 | act(() => { 189 | jest.advanceTimersByTime(400); 190 | }); 191 | 192 | // timeout shouldn't have been called yet 193 | // @ts-ignore 194 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 195 | 196 | act(() => { 197 | tree.rerender(); 198 | }); 199 | 200 | act(() => { 201 | jest.advanceTimersByTime(400); 202 | }); 203 | // after runAllTimer text should be updated 204 | // @ts-ignore 205 | expect(screen.getByRole('test')).toHaveTextContent('Right value'); 206 | }); 207 | 208 | it("shouldn't apply the previous value if it was changed to started one", () => { 209 | function Component({ text }) { 210 | const [value] = useDebounce(text, 500); 211 | return
{value}
; 212 | } 213 | 214 | const tree = render(); 215 | 216 | act(() => { 217 | // this value shouldn't be applied, as we'll set up another one 218 | tree.rerender(); 219 | }); 220 | 221 | // timeout shouldn't have been called yet 222 | // @ts-ignore 223 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 224 | 225 | act(() => { 226 | tree.rerender(); 227 | }); 228 | 229 | act(() => { 230 | jest.advanceTimersByTime(500); 231 | }); 232 | 233 | // Value shouldn't be changed, as we rerender Component with text prop === 'Hello' 234 | // @ts-ignore 235 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 236 | }); 237 | 238 | it("shouldn't rerender component for the first time", () => { 239 | function Component({ text }) { 240 | const [value] = useDebounce(text, 1000, { maxWait: 500 }); 241 | const rerenderCounter = React.useRef(0); 242 | rerenderCounter.current += 1; 243 | return
{rerenderCounter.current}
; 244 | } 245 | 246 | const tree = render(); 247 | 248 | // @ts-ignore 249 | expect(screen.getByRole('test')).toHaveTextContent('1'); 250 | 251 | act(() => { 252 | // We wait for the half of maxWait Timeout, 253 | jest.advanceTimersByTime(250); 254 | }); 255 | 256 | act(() => { 257 | tree.rerender(); 258 | }); 259 | 260 | // @ts-ignore 261 | expect(screen.getByRole('test')).toHaveTextContent('2'); 262 | 263 | act(() => { 264 | // We wait for the maxWait Timeout, 265 | jest.advanceTimersByTime(250); 266 | }); 267 | 268 | // If maxWait wasn't started at the first render of the component, we shouldn't receive the new value 269 | // @ts-ignore 270 | expect(screen.getByRole('test')).toHaveTextContent('2'); 271 | }); 272 | 273 | it('should use equality function if supplied', () => { 274 | // Use equality function that always returns true 275 | const eq = jest.fn((left: string, right: string): boolean => { 276 | return true; 277 | }); 278 | 279 | function Component({ text }) { 280 | const [value] = useDebounce(text, 1000, { equalityFn: eq }); 281 | return
{value}
; 282 | } 283 | 284 | const tree = render(); 285 | 286 | expect(eq).toHaveBeenCalledTimes(1); 287 | 288 | act(() => { 289 | tree.rerender(); 290 | }); 291 | 292 | expect(eq).toHaveBeenCalledTimes(2); 293 | expect(eq).toHaveBeenCalledWith('Hello', 'Test'); 294 | // Since the equality function always returns true, expect the value to stay the same 295 | // @ts-ignore 296 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 297 | }); 298 | 299 | it('should setup new value immediately if callPending is called', () => { 300 | let callPending; 301 | function Component({ text }) { 302 | const [value, fn] = useDebounce(text, 1000); 303 | callPending = fn.flush; 304 | 305 | return
{value}
; 306 | } 307 | 308 | const tree = render(); 309 | 310 | // @ts-ignore 311 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 312 | 313 | act(() => { 314 | tree.rerender(); 315 | }); 316 | 317 | // We don't call neither runTimers no callPending. 318 | // @ts-ignore 319 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 320 | 321 | act(() => { 322 | callPending(); 323 | }); 324 | 325 | // @ts-ignore 326 | expect(screen.getByRole('test')).toHaveTextContent('Test'); 327 | }); 328 | 329 | it('should preserve debounced object between re-renders', () => { 330 | let cachedDebounced: unknown = null; 331 | function Component({ text }) { 332 | const [value, debounced] = useDebounce(text, 1000); 333 | if (cachedDebounced == null) { 334 | cachedDebounced = debounced; 335 | } else { 336 | expect(cachedDebounced).toBe(debounced); 337 | } 338 | return
{value}
; 339 | } 340 | const tree = render(); 341 | 342 | // check inited value 343 | // @ts-ignore 344 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 345 | 346 | act(() => { 347 | tree.rerender(); 348 | }); 349 | // timeout shouldn't have called yet 350 | // @ts-ignore 351 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 352 | 353 | act(() => { 354 | jest.runAllTimers(); 355 | }); 356 | // after runAllTimer text should be updated 357 | // @ts-ignore 358 | expect(screen.getByRole('test')).toHaveTextContent('Hello world'); 359 | }); 360 | 361 | it('should change debounced.isPending to true as soon as the function is called in a sync way', () => { 362 | function Component({ text }) { 363 | const [value, { isPending }] = useDebounce(text, 1000); 364 | if (value === text) { 365 | expect(isPending()).toBeFalsy(); 366 | } else { 367 | expect(isPending()).toBeTruthy(); 368 | } 369 | return
{value}
; 370 | } 371 | const tree = render(); 372 | 373 | // check inited value 374 | // @ts-ignore 375 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 376 | 377 | act(() => { 378 | tree.rerender(); 379 | }); 380 | // timeout shouldn't have called yet 381 | // @ts-ignore 382 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 383 | 384 | act(() => { 385 | jest.runAllTimers(); 386 | }); 387 | // after runAllTimer text should be updated 388 | // @ts-ignore 389 | expect(screen.getByRole('test')).toHaveTextContent('Hello world'); 390 | }); 391 | 392 | it('Should use function as debounced value', () => { 393 | function Component({ fn }) { 394 | const [value] = useDebounce(fn, 1000); 395 | return
{value()}
; 396 | } 397 | const tree = render( 'Hello'} />); 398 | 399 | // check inited value 400 | // @ts-ignore 401 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 402 | 403 | act(() => { 404 | tree.rerender( 'Hello world'} />); 405 | }); 406 | // timeout shouldn't have called yet 407 | // @ts-ignore 408 | expect(screen.getByRole('test')).toHaveTextContent('Hello'); 409 | 410 | act(() => { 411 | jest.runAllTimers(); 412 | }); 413 | // after runAllTimer text should be updated 414 | // @ts-ignore 415 | expect(screen.getByRole('test')).toHaveTextContent('Hello world'); 416 | }); 417 | 418 | 419 | it('Handles isPending', () => { 420 | function Component({propValue}) { 421 | const [value, fns] = useDebounce(propValue, 1000); 422 | return ( 423 |
424 |
{value}
425 |
{fns.isPending().toString()}
426 |
427 | ); 428 | } 429 | 430 | const tree = render(); 431 | 432 | // check inited value 433 | // @ts-ignore 434 | expect(screen.getByRole('value')).toHaveTextContent('Hello'); 435 | // @ts-ignore 436 | expect(screen.getByRole('pending')).toHaveTextContent('false'); 437 | 438 | act(() => { 439 | tree.rerender(); 440 | }); 441 | // timeout shouldn't have called yet 442 | // @ts-ignore 443 | expect(screen.getByRole('value')).toHaveTextContent('Hello'); 444 | // @ts-ignore 445 | expect(screen.getByRole('pending')).toHaveTextContent('true'); 446 | 447 | act(() => { 448 | jest.runAllTimers(); 449 | }); 450 | // after runAllTimer text should be updated 451 | // @ts-ignore 452 | expect(screen.getByRole('value')).toHaveTextContent('Hello 1'); 453 | // @ts-ignore 454 | expect(screen.getByRole('pending')).toHaveTextContent('false'); 455 | }) 456 | 457 | it('Should handle isPending state correctly while switching between bounced values', () => { 458 | function Component({propValue}) { 459 | const [value, fns] = useDebounce(propValue, 1000); 460 | return ( 461 |
462 |
{value}
463 |
{fns.isPending().toString()}
464 |
465 | ); 466 | } 467 | 468 | const tree = render(); 469 | 470 | // check inited value 471 | // @ts-ignore 472 | expect(screen.getByRole('value')).toHaveTextContent('Hello'); 473 | // @ts-ignore 474 | expect(screen.getByRole('pending')).toHaveTextContent('false'); 475 | 476 | act(() => { 477 | tree.rerender(); 478 | }); 479 | // timeout shouldn't have called yet 480 | // @ts-ignore 481 | expect(screen.getByRole('value')).toHaveTextContent('Hello'); 482 | // @ts-ignore 483 | expect(screen.getByRole('pending')).toHaveTextContent('true'); 484 | 485 | act(() => { 486 | tree.rerender(); 487 | }); 488 | 489 | // timeout shouldn't have called yet 490 | // @ts-ignore 491 | expect(screen.getByRole('value')).toHaveTextContent('Hello'); 492 | // @ts-ignore 493 | expect(screen.getByRole('pending')).toHaveTextContent('true'); 494 | 495 | act(() => { 496 | jest.runAllTimers(); 497 | }); 498 | // after runAllTimer text should be updated 499 | // @ts-ignore 500 | expect(screen.getByRole('value')).toHaveTextContent('Hello'); 501 | // @ts-ignore 502 | expect(screen.getByRole('pending')).toHaveTextContent('false'); 503 | }) 504 | }); 505 | -------------------------------------------------------------------------------- /test/useDebouncedCallback.test.tsx: -------------------------------------------------------------------------------- 1 | import { render, screen, act, fireEvent } from '@testing-library/react'; 2 | import { useEffect, useCallback, useRef } from 'react'; 3 | import * as React from 'react'; 4 | import useDebouncedCallback from '../src/useDebouncedCallback'; 5 | import { describe, it, expect, jest, beforeEach, test } from '@jest/globals'; 6 | 7 | describe('useDebouncedCallback', () => { 8 | beforeEach(() => { 9 | jest.useFakeTimers(); 10 | }); 11 | it('will call callback when timeout is called', () => { 12 | const callback = jest.fn(); 13 | 14 | function Component() { 15 | const debounced = useDebouncedCallback(callback, 1000); 16 | debounced(); 17 | return null; 18 | } 19 | render(); 20 | 21 | expect(callback.mock.calls.length).toBe(0); 22 | 23 | act(() => { 24 | jest.runAllTimers(); 25 | }); 26 | 27 | expect(callback.mock.calls.length).toBe(1); 28 | }); 29 | 30 | it('will call leading callback immediately (but only once, as trailing is set to false)', () => { 31 | const callback = jest.fn(); 32 | 33 | function Component() { 34 | const debounced = useDebouncedCallback(callback, 1000, { 35 | leading: true, 36 | trailing: false, 37 | }); 38 | debounced(); 39 | return null; 40 | } 41 | render(); 42 | 43 | expect(callback.mock.calls.length).toBe(1); 44 | 45 | act(() => { 46 | jest.runAllTimers(); 47 | }); 48 | 49 | expect(callback.mock.calls.length).toBe(1); 50 | }); 51 | 52 | it('will call leading callback as well as next debounced call', () => { 53 | const callback = jest.fn(); 54 | 55 | function Component() { 56 | const debounced = useDebouncedCallback(callback, 1000, { leading: true }); 57 | debounced(); 58 | debounced(); 59 | return null; 60 | } 61 | render(); 62 | 63 | expect(callback.mock.calls.length).toBe(1); 64 | 65 | act(() => { 66 | jest.runAllTimers(); 67 | }); 68 | 69 | expect(callback.mock.calls.length).toBe(2); 70 | }); 71 | 72 | it('will call three callbacks if no debounced callbacks are pending', () => { 73 | const callback = jest.fn(); 74 | 75 | function Component() { 76 | const debounced = useDebouncedCallback(callback, 1000, { leading: true }); 77 | debounced(); 78 | debounced(); 79 | setTimeout(() => { 80 | debounced(); 81 | }, 1001); 82 | return null; 83 | } 84 | render(); 85 | 86 | expect(callback.mock.calls.length).toBe(1); 87 | 88 | act(() => { 89 | jest.advanceTimersByTime(1001); 90 | }); 91 | 92 | expect(callback.mock.calls.length).toBe(3); 93 | }); 94 | 95 | it('Subsequent calls to the debounced function `debounced.callback` return the result of the last func invocation.', () => { 96 | const callback = jest.fn(() => 42); 97 | 98 | let callbackCache; 99 | function Component() { 100 | const debounced = useDebouncedCallback(callback, 1000); 101 | callbackCache = debounced; 102 | return null; 103 | } 104 | render(); 105 | 106 | const result = callbackCache(); 107 | expect(callback.mock.calls.length).toBe(0); 108 | expect(result).toBeUndefined(); 109 | 110 | act(() => { 111 | jest.runAllTimers(); 112 | }); 113 | expect(callback.mock.calls.length).toBe(1); 114 | const subsequentResult = callbackCache(); 115 | 116 | expect(callback.mock.calls.length).toBe(1); 117 | expect(subsequentResult).toBe(42); 118 | }); 119 | 120 | it('will call a second leading callback if no debounced callbacks are pending with trailing false', () => { 121 | const callback = jest.fn(); 122 | 123 | function Component() { 124 | const debounced = useDebouncedCallback(callback, 1000, { 125 | leading: true, 126 | trailing: false, 127 | }); 128 | debounced(); 129 | setTimeout(() => { 130 | debounced(); 131 | }, 1001); 132 | return null; 133 | } 134 | render(); 135 | 136 | expect(callback.mock.calls.length).toBe(1); 137 | 138 | act(() => { 139 | jest.advanceTimersByTime(1001); 140 | }); 141 | 142 | expect(callback.mock.calls.length).toBe(2); 143 | }); 144 | 145 | it("won't call both on the leading edge and on the trailing edge if leading and trailing are set up to true and function call is only once", () => { 146 | const callback = jest.fn(); 147 | 148 | function Component() { 149 | // trailing is true by default 150 | const debounced = useDebouncedCallback(callback, 1000, { leading: true }); 151 | 152 | debounced(); 153 | return null; 154 | } 155 | render(); 156 | 157 | expect(callback.mock.calls.length).toBe(1); 158 | 159 | act(() => { 160 | jest.runAllTimers(); 161 | }); 162 | 163 | expect(callback.mock.calls.length).toBe(1); 164 | }); 165 | 166 | it('will call both on the leading edge and on the trailing edge if leading and trailing are set up to true and there are more than 1 function call', () => { 167 | const callback = jest.fn(); 168 | 169 | function Component() { 170 | // trailing is true by default 171 | const debounced = useDebouncedCallback(callback, 1000, { leading: true }); 172 | debounced(); 173 | debounced(); 174 | return null; 175 | } 176 | render(); 177 | 178 | expect(callback.mock.calls.length).toBe(1); 179 | 180 | act(() => { 181 | jest.runAllTimers(); 182 | }); 183 | 184 | expect(callback.mock.calls.length).toBe(2); 185 | }); 186 | 187 | test.each` 188 | options | _0 | _190 | _200 | _210 | _500 189 | ${{ leading: true, trailing: true }} | ${1} | ${1} | ${1} | ${1} | ${2} 190 | ${{ leading: true, trailing: false }} | ${1} | ${1} | ${1} | ${1} | ${1} 191 | ${{ leading: false, trailing: true }} | ${0} | ${0} | ${0} | ${0} | ${1} 192 | ${{ leading: false, trailing: false }} | ${0} | ${0} | ${0} | ${0} | ${0} 193 | ${{ leading: true, trailing: true, maxWait: 190 }} | ${1} | ${1} | ${2} | ${2} | ${3} 194 | ${{ leading: true, trailing: false, maxWait: 190 }} | ${1} | ${1} | ${1} | ${2} | ${2} 195 | ${{ leading: false, trailing: true, maxWait: 190 }} | ${0} | ${0} | ${1} | ${1} | ${2} 196 | ${{ leading: true, trailing: true, maxWait: 200 }} | ${1} | ${1} | ${2} | ${2} | ${3} 197 | ${{ leading: true, trailing: false, maxWait: 200 }} | ${1} | ${1} | ${1} | ${2} | ${2} 198 | ${{ leading: false, trailing: true, maxWait: 200 }} | ${0} | ${0} | ${1} | ${1} | ${2} 199 | ${{ leading: false, trailing: false, maxWait: 200 }} | ${0} | ${0} | ${0} | ${0} | ${0} 200 | ${{ leading: true, trailing: true, maxWait: 210 }} | ${1} | ${1} | ${1} | ${2} | ${3} 201 | ${{ leading: true, trailing: false, maxWait: 210 }} | ${1} | ${1} | ${1} | ${1} | ${2} 202 | ${{ leading: false, trailing: true, maxWait: 210 }} | ${0} | ${0} | ${0} | ${1} | ${2} 203 | `('options=$options', ({ options, _0, _190, _200, _210, _500 }) => { 204 | const callback = jest.fn(); 205 | 206 | function Component() { 207 | // @ts-ignore 208 | const debounced = useDebouncedCallback(callback, 200, options); 209 | 210 | debounced(); 211 | expect(callback.mock.calls.length).toBe(_0); 212 | 213 | setTimeout(() => { 214 | expect(callback.mock.calls.length).toBe(_190); 215 | debounced(); 216 | }, 191); 217 | 218 | setTimeout(() => { 219 | expect(callback.mock.calls.length).toBe(_200); 220 | debounced(); 221 | }, 201); 222 | 223 | setTimeout(() => { 224 | expect(callback.mock.calls.length).toBe(_210); 225 | debounced(); 226 | }, 211); 227 | 228 | setTimeout(() => { 229 | expect(callback.mock.calls.length).toBe(_500); 230 | }, 500); 231 | 232 | return null; 233 | } 234 | render(); 235 | 236 | act(() => { 237 | jest.runAllTimers(); 238 | }); 239 | }); 240 | 241 | it('will call callback only with the latest params', () => { 242 | const callback = jest.fn((param) => { 243 | expect(param).toBe('Right param'); 244 | }); 245 | 246 | function Component() { 247 | const debounced = useDebouncedCallback(callback, 1000); 248 | debounced('Wrong param'); 249 | setTimeout(() => { 250 | debounced('Right param'); 251 | }, 500); 252 | return null; 253 | } 254 | render(); 255 | 256 | act(() => { 257 | jest.advanceTimersByTime(500); 258 | }); 259 | expect(callback.mock.calls.length).toBe(0); 260 | 261 | act(() => { 262 | jest.advanceTimersByTime(1000); 263 | }); 264 | 265 | expect(callback.mock.calls.length).toBe(1); 266 | }); 267 | 268 | it('will cancel delayed callback when cancel method is called', () => { 269 | const callback = jest.fn(); 270 | 271 | function Component() { 272 | const debounced = useDebouncedCallback(callback, 1000); 273 | debounced(); 274 | setTimeout(debounced.cancel, 500); 275 | return null; 276 | } 277 | render(); 278 | 279 | act(() => { 280 | jest.runAllTimers(); 281 | }); 282 | 283 | expect(callback.mock.calls.length).toBe(0); 284 | }); 285 | 286 | it('will change callback function, if params from dependencies has changed', () => { 287 | function Component({ text }) { 288 | const debounced = useDebouncedCallback( 289 | useCallback( 290 | jest.fn(() => { 291 | expect(text).toBe('Right param'); 292 | }), 293 | [text] 294 | ), 295 | 1000 296 | ); 297 | return