Skip to content

Commit

Permalink
components: Add useLatestRef hook (#33137)
Browse files Browse the repository at this point in the history
* components: Add usePropRef hook

* Rename to useLatestRef

Co-authored-by: Haz <[email protected]>

* Use useIsomorphicLayoutEffect

* Update documentation to point to codesandbox example

* Remove unit tests

* Add unit tests

Co-authored-by: Haz <[email protected]>
  • Loading branch information
sarayourfriend and diegohaz authored Jul 12, 2021
1 parent 28db73d commit dc1821f
Show file tree
Hide file tree
Showing 3 changed files with 151 additions and 0 deletions.
1 change: 1 addition & 0 deletions packages/components/src/utils/hooks/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export { default as useJumpStep } from './use-jump-step';
export { default as useUpdateEffect } from './use-update-effect';
export { useControlledValue } from './use-controlled-value';
export { useCx } from './use-cx';
export { useLatestRef } from './use-latest-ref';
120 changes: 120 additions & 0 deletions packages/components/src/utils/hooks/test/use-latest-ref.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
/**
* External dependencies
*/
import { render, screen, fireEvent, waitFor } from '@testing-library/react';

/**
* WordPress dependencies
*/
import { useState } from '@wordpress/element';

/**
* Internal dependencies
*/
import { useLatestRef } from '..';

function debounce( callback, timeout = 0 ) {
let timeoutId = 0;
return ( ...args ) => {
window.clearTimeout( timeoutId );
timeoutId = window.setTimeout( () => callback( ...args ), timeout );
};
}

function useDebounce( callback, timeout = 0 ) {
const callbackRef = useLatestRef( callback );
return debounce( ( ...args ) => callbackRef.current( ...args ), timeout );
}

function Example() {
const [ count, setCount ] = useState( 0 );
const increment = () => setCount( count + 1 );
const incrementDebounced = debounce( increment, 50 );
const incrementDebouncedWithLatestRef = useDebounce( increment, 50 );

return (
<>
<div>Count: { count }</div>
<button onClick={ incrementDebounced }>Increment debounced</button>
<button onClick={ increment }>Increment immediately</button>
<br />
<button onClick={ incrementDebouncedWithLatestRef }>
Increment debounced with latest ref
</button>
</>
);
}

function sleep( milliseconds ) {
return new Promise( ( resolve ) =>
window.setTimeout( resolve, milliseconds )
);
}

function getCount() {
return screen.getByText( /Count:/ ).innerHTML;
}

function incrementCount() {
fireEvent.click( screen.getByText( 'Increment immediately' ) );
}

function incrementCountDebounced() {
fireEvent.click( screen.getByText( 'Increment debounced' ) );
}

function incrementCountDebouncedRef() {
fireEvent.click(
screen.getByText( 'Increment debounced with latest ref' )
);
}

describe( 'useLatestRef', () => {
describe( 'Example', () => {
// prove the example works as expected
it( 'should start at 0', () => {
render( <Example /> );
expect( getCount() ).toEqual( 'Count: 0' );
} );

it( 'should increment immediately', () => {
render( <Example /> );
incrementCount();
expect( getCount() ).toEqual( 'Count: 1' );
} );

it( 'should increment after debouncing', async () => {
render( <Example /> );
incrementCountDebounced();
expect( getCount() ).toEqual( 'Count: 0' );

await waitFor( () => sleep( 0 ) );
expect( getCount() ).toEqual( 'Count: 1' );
} );

it( 'should increment after debouncing with latest ref', async () => {
render( <Example /> );
incrementCountDebouncedRef();
expect( getCount() ).toEqual( 'Count: 0' );

await waitFor( () => sleep( 0 ) );
expect( getCount() ).toEqual( 'Count: 1' );
} );
} );

it( 'should increment to one', async () => {
render( <Example /> );
incrementCountDebounced();
incrementCount();
await waitFor( () => sleep( 0 ) );
expect( getCount() ).toEqual( 'Count: 1' );
} );

it( 'should increment to two', async () => {
render( <Example /> );
incrementCountDebouncedRef();
incrementCount();
await waitFor( () => sleep( 0 ) );
expect( getCount() ).toEqual( 'Count: 2' );
} );
} );
30 changes: 30 additions & 0 deletions packages/components/src/utils/hooks/use-latest-ref.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
/**
* External dependencies
*/
// eslint-disable-next-line no-restricted-imports
import type { RefObject } from 'react';

/**
* WordPress dependencies
*/
import { useRef } from '@wordpress/element';
import { useIsomorphicLayoutEffect } from '@wordpress/compose';

/**
* Creates a reference for a prop. This is useful for preserving dependency
* memoization for hooks like useCallback.
*
* @see https://codesandbox.io/s/uselatestref-mlj3i?file=/src/App.tsx
*
* @param value The value to reference
* @return The prop reference.
*/
export function useLatestRef< T >( value: T ): RefObject< T > {
const ref = useRef( value );

useIsomorphicLayoutEffect( () => {
ref.current = value;
} );

return ref;
}

0 comments on commit dc1821f

Please sign in to comment.