forked from carbon-design-system/carbon
-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(react-hooks): add useDebounce and useThrottle (carbon-design-sys…
- Loading branch information
Showing
33 changed files
with
658 additions
and
0 deletions.
There are no files selected for viewing
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
/** | ||
* Copyright IBM Corp. 2018, 2018 | ||
* | ||
* This source code is licensed under the Apache-2.0 license found in the | ||
* LICENSE file in the root directory of this source tree. | ||
*/ | ||
|
||
import { render, cleanup } from '@carbon/test-utils/react'; | ||
import React, { useState } from 'react'; | ||
import { act } from 'react-dom/test-utils'; | ||
import { useDebounce } from '../useDebounce'; | ||
|
||
jest.useFakeTimers(); | ||
|
||
describe('useDebounce', () => { | ||
afterEach(cleanup); | ||
|
||
it('should debounce updating based on the received value', () => { | ||
let count; | ||
let updateCount; | ||
let debouncedValue; | ||
|
||
function Component() { | ||
[count, updateCount] = useState(0); | ||
[debouncedValue] = useDebounce(count, 100); | ||
|
||
return ( | ||
<button onClick={() => updateCount(count + 1)}>{debouncedValue}</button> | ||
); | ||
} | ||
|
||
const { container } = render(<Component />); | ||
const button = container.querySelector('button'); | ||
|
||
expect(debouncedValue).toBe(count); | ||
button.click(); | ||
expect(debouncedValue).not.toBe(count); | ||
|
||
act(() => { | ||
jest.runAllTimers(); | ||
}); | ||
|
||
expect(debouncedValue).toBe(count); | ||
}); | ||
|
||
it('should stop updates until some time after the value has settled', () => { | ||
let count; | ||
let updateCount; | ||
let debouncedValue; | ||
|
||
function Component() { | ||
[count, updateCount] = useState(0); | ||
[debouncedValue] = useDebounce(count, 100); | ||
|
||
return ( | ||
<button onClick={() => updateCount(count + 1)}>{debouncedValue}</button> | ||
); | ||
} | ||
|
||
const { container } = render(<Component />); | ||
const intervalId = setInterval(() => { | ||
act(() => { | ||
const button = container.querySelector('button'); | ||
button.click(); | ||
}); | ||
}, 100); | ||
|
||
expect(debouncedValue).toBe(count); | ||
jest.advanceTimersByTime(100); | ||
jest.advanceTimersByTime(100); | ||
jest.advanceTimersByTime(100); | ||
expect(debouncedValue).not.toBe(count); | ||
clearInterval(intervalId); | ||
|
||
act(() => { | ||
jest.runAllTimers(); | ||
}); | ||
expect(debouncedValue).toBe(count); | ||
}); | ||
|
||
it('should update immediately if leading is set', () => { | ||
let count; | ||
let updateCount; | ||
let debouncedValue; | ||
|
||
function Component() { | ||
[count, updateCount] = useState(0); | ||
[debouncedValue] = useDebounce(count, 100, { | ||
leading: true, | ||
}); | ||
|
||
return ( | ||
<button onClick={() => updateCount(count + 1)}>{debouncedValue}</button> | ||
); | ||
} | ||
|
||
const { container } = render(<Component />); | ||
expect(debouncedValue).toBe(count); | ||
|
||
const button = container.querySelector('button'); | ||
act(() => { | ||
button.click(); | ||
}); | ||
expect(debouncedValue).toBe(count); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
/** | ||
* Copyright IBM Corp. 2018, 2018 | ||
* | ||
* This source code is licensed under the Apache-2.0 license found in the | ||
* LICENSE file in the root directory of this source tree. | ||
*/ | ||
|
||
import { storiesOf } from '@storybook/react'; | ||
import React, { useEffect, useState } from 'react'; | ||
import { useDebounce } from './useDebounce'; | ||
|
||
function useTimedUpdate(interval, { maxValue = 10 } = {}) { | ||
const [value, updateValue] = useState(0); | ||
|
||
useEffect(() => { | ||
function handler() { | ||
updateValue(value => { | ||
if (value + 1 > maxValue) { | ||
window.clearInterval(timerId); | ||
return value; | ||
} | ||
return value + 1; | ||
}); | ||
} | ||
|
||
const timerId = window.setInterval(handler, interval); | ||
return () => { | ||
window.clearInterval(timerId); | ||
}; | ||
}, [interval, maxValue]); | ||
|
||
return value; | ||
} | ||
|
||
storiesOf('useDebounce', module) | ||
.add('default', () => { | ||
function DemoComponent() { | ||
const value = useTimedUpdate(100); | ||
const debouncedValue = useDebounce(value, 500); | ||
return ( | ||
<dl> | ||
<dt>Value</dt> | ||
<dd>{value}</dd> | ||
<dt>Debounced value</dt> | ||
<dd>{debouncedValue}</dd> | ||
</dl> | ||
); | ||
} | ||
return <DemoComponent />; | ||
}) | ||
.add('leading', () => { | ||
function DemoComponent() { | ||
const value = useTimedUpdate(1000, { maxValue: 10 }); | ||
const debouncedValue = useDebounce(value, 1500, { | ||
leading: true, | ||
trailing: false, | ||
}); | ||
return ( | ||
<dl> | ||
<dt>Value</dt> | ||
<dd>{value}</dd> | ||
<dt>Debounced value</dt> | ||
<dd>{debouncedValue}</dd> | ||
</dl> | ||
); | ||
} | ||
return <DemoComponent />; | ||
}) | ||
.add('maxWait', () => { | ||
function DemoComponent() { | ||
const value = useTimedUpdate(100); | ||
const debouncedValue = useDebounce(value, 500, { | ||
maxWait: 1000, | ||
}); | ||
return ( | ||
<dl> | ||
<dt>Value</dt> | ||
<dd>{value}</dd> | ||
<dt>Debounced value with max wait (1000ms)</dt> | ||
<dd>{debouncedValue}</dd> | ||
</dl> | ||
); | ||
} | ||
|
||
return <DemoComponent />; | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,149 @@ | ||
/** | ||
* Copyright IBM Corp. 2018, 2018 | ||
* | ||
* This source code is licensed under the Apache-2.0 license found in the | ||
* LICENSE file in the root directory of this source tree. | ||
*/ | ||
|
||
import { useEffect, useRef, useState } from 'react'; | ||
|
||
/** | ||
* Returns a debounced value that delays being updated until after `wait` | ||
* milliseconds have elapsed since the last time the value was changed. The | ||
* result of this hook comes with a `cancel` method to cancel delayed updates. | ||
* It also supports options for when the value should be updated relative to | ||
* the timer, on the leading and/or trailing edge. | ||
* | ||
* @param {any} value | ||
* @param {number} wait | ||
* @param {object} options | ||
* @param {boolean} options.leading | ||
* @param {number} options.maxWait | ||
* @param {boolean} options.trailing | ||
* @returns {[any, Function]} | ||
*/ | ||
export function useDebounce(value, wait = 0, options = {}) { | ||
const { leading = false, maxWait, trailing = true } = options; | ||
const [debouncedValue, setDebouncedValue] = useState(value); | ||
|
||
// We keep track of several mutable values across renders given that we often | ||
// want to know information about when the render itself has occurred in order | ||
// to correctly debounce the value. | ||
const savedValue = useRef(value); | ||
const timerId = useRef(null); | ||
// We keep track of `lastCallTime` so that we can determine if enough time has | ||
// passed for us to update the debounced value | ||
const lastCallTime = useRef(null); | ||
// We keep track of `lastUpdate` so that we can determine if enough time has | ||
// passed that our `maxWait` threshold has been hit | ||
const lastUpdate = useRef(null); | ||
|
||
/** | ||
* Cancel any currently running timers and reset any internal mutable values | ||
* that we're tracking | ||
*/ | ||
function cancel() { | ||
if (timerId.current !== null) { | ||
clearTimeout(timerId.current); | ||
} | ||
timerId.current = null; | ||
lastCallTime.current = null; | ||
} | ||
|
||
// We'll need to cancel any existing timers if any of the configuration | ||
// options for the timer have changed, or if the hook itself has been | ||
// un-mounted. We separate out this cancellation from the debounce effect | ||
// below so that we're not cancelling timers we create every time the | ||
// value has changed. | ||
useEffect(cancel, [wait, leading, trailing]); | ||
useEffect(() => cancel, []); | ||
|
||
// Each time our value changes, we're going to run our "debounce" effect that | ||
// will try and create a new timer if one does not exist already. It's | ||
// important that this hook runs after the cancellation hooks above so that | ||
// any timers associated with a previous configuration value have been | ||
// cancelled. | ||
useEffect(() => { | ||
// If our values are the same, there's no reason to kick-off a timer. This | ||
// check is important so that the first value received does not schedule a | ||
// timer. | ||
if (value === debouncedValue) { | ||
return; | ||
} | ||
|
||
// For each call to our debounce effect we're going to keep track | ||
// of the current value and the time when this effect was invoked. We keep | ||
// track of both so that when our timers are invoked we have fresh values | ||
// for both to determine either what value to update internal state with, or | ||
// how long a new timer needs to be created for. | ||
savedValue.current = value; | ||
lastCallTime.current = Date.now(); | ||
|
||
// If we already have a timer, no need to create another one. | ||
if (timerId.current !== null) { | ||
return; | ||
} | ||
|
||
/** | ||
* Determine if we should update the `debouncedValue`. There are two signals | ||
* where we should update, namely if we've waited enough time or if we've hit | ||
* the `maxWait` threshold | ||
* @param {number} time | ||
*/ | ||
function shouldUpdate(time) { | ||
const timeSinceLastCall = time - lastCallTime.current; | ||
const timeSinceLastUpdate = time - lastUpdate.current; | ||
return ( | ||
timeSinceLastCall >= wait || (maxWait && timeSinceLastUpdate >= maxWait) | ||
); | ||
} | ||
|
||
/** | ||
* Used as the handler to our `setTimeout` calls. This function will determine | ||
* if we are able to update the debouncedValue, or if we'll need to schedule a | ||
* timer to run for the remaining time. | ||
*/ | ||
function timerExpired() { | ||
const time = Date.now(); | ||
if (shouldUpdate(time)) { | ||
if (trailing) { | ||
lastUpdate.current = Date.now(); | ||
setDebouncedValue(savedValue.current); | ||
} | ||
|
||
timerId.current = null; | ||
lastCallTime.current = null; | ||
return; | ||
} | ||
timerId.current = setTimeout(timerExpired, getRemainingTime(time)); | ||
} | ||
|
||
/** | ||
* Get the remaining time for a `setTimeout` call based on the current `time`. | ||
* If `maxWait` has been specified, we'll choose the minimum between how long | ||
* we've been waiting and how much time we have left before hitting our | ||
* `maxWait` threshold. Otherwise, we'll use the time since the last call to | ||
* schedule the timer. | ||
* @param {number} time | ||
*/ | ||
function getRemainingTime(time) { | ||
const timeSinceLastCall = time - lastCallTime.current; | ||
const timeSinceLastUpdate = time - lastUpdate.current; | ||
const timeWaiting = wait - timeSinceLastCall; | ||
return maxWait | ||
? Math.min(timeWaiting, maxWait - timeSinceLastUpdate) | ||
: timeSinceLastCall; | ||
} | ||
|
||
timerId.current = setTimeout(timerExpired, wait); | ||
|
||
// If a user has specified the `leading` option, let's update the | ||
// debounced value immediately | ||
if (leading) { | ||
lastUpdate.current = Date.now(); | ||
setDebouncedValue(savedValue.current); | ||
} | ||
}, [value, debouncedValue, wait, leading, trailing, maxWait]); | ||
|
||
return [debouncedValue, cancel]; | ||
} |
Oops, something went wrong.