Skip to content

Commit

Permalink
feat(react-hooks): add useDebounce and useThrottle (carbon-design-sys…
Browse files Browse the repository at this point in the history
  • Loading branch information
joshblack authored Sep 18, 2019
1 parent 43d9cc0 commit 04934f2
Show file tree
Hide file tree
Showing 33 changed files with 658 additions and 0 deletions.
Binary file added .yarn/offline-mirror/@babel-runtime-7.6.0.tgz
Binary file not shown.
Binary file added .yarn/offline-mirror/@emotion-cache-10.0.17.tgz
Binary file not shown.
Binary file added .yarn/offline-mirror/@emotion-core-10.0.17.tgz
Binary file not shown.
Binary file not shown.
Binary file added .yarn/offline-mirror/@emotion-styled-10.0.17.tgz
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added .yarn/offline-mirror/@storybook-addons-5.2.0.tgz
Binary file not shown.
Binary file added .yarn/offline-mirror/@storybook-api-5.2.0.tgz
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added .yarn/offline-mirror/@storybook-router-5.2.0.tgz
Binary file not shown.
Binary file not shown.
Binary file added .yarn/offline-mirror/@storybook-theming-5.2.0.tgz
Binary file not shown.
Binary file added .yarn/offline-mirror/@types-history-4.7.3.tgz
Binary file not shown.
Binary file added .yarn/offline-mirror/@types-prop-types-15.7.2.tgz
Binary file not shown.
Binary file not shown.
Binary file added .yarn/offline-mirror/@types-react-16.9.2.tgz
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file added .yarn/offline-mirror/csstype-2.6.6.tgz
Binary file not shown.
Binary file added .yarn/offline-mirror/emotion-theming-10.0.18.tgz
Binary file not shown.
Binary file added .yarn/offline-mirror/telejson-2.2.2.tgz
Binary file not shown.
1 change: 1 addition & 0 deletions packages/react-hooks/.storybook/addons.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@

import '@storybook/addon-actions/register';
import '@storybook/addon-links/register';
import '@storybook/addon-storysource/register';
6 changes: 6 additions & 0 deletions packages/react-hooks/.storybook/webpack.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,11 @@ module.exports = ({ config, mode }) => {
],
});

config.module.rules.push({
test: /-story\.jsx?$/,
loaders: [require.resolve('@storybook/source-loader')],
enforce: 'pre',
});

return config;
};
2 changes: 2 additions & 0 deletions packages/react-hooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,10 @@
"@babel/core": "^7.5.5",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"@carbon/test-utils": "10.3.0",
"@storybook/addon-actions": "^5.1.11",
"@storybook/addon-links": "^5.1.11",
"@storybook/addon-storysource": "^5.2.0",
"@storybook/addons": "^5.1.11",
"@storybook/react": "^5.1.11",
"autoprefixer": "^9.6.1",
Expand Down
106 changes: 106 additions & 0 deletions packages/react-hooks/src/__tests__/useDebounce-test.js
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);
});
});
86 changes: 86 additions & 0 deletions packages/react-hooks/src/useDebounce-story.js
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 />;
});
149 changes: 149 additions & 0 deletions packages/react-hooks/src/useDebounce.js
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];
}
Loading

0 comments on commit 04934f2

Please sign in to comment.