diff --git a/packages/components/src/utils/hooks/index.js b/packages/components/src/utils/hooks/index.js index 3eeca7881e2563..187718e5e70a9a 100644 --- a/packages/components/src/utils/hooks/index.js +++ b/packages/components/src/utils/hooks/index.js @@ -1,3 +1,4 @@ export { default as useControlledState } from './use-controlled-state'; export { default as useJumpStep } from './use-jump-step'; export { default as useUpdateEffect } from './use-update-effect'; +export { useControlledValue } from './use-controlled-value'; diff --git a/packages/components/src/utils/hooks/test/use-controlled-value.js b/packages/components/src/utils/hooks/test/use-controlled-value.js new file mode 100644 index 00000000000000..1d63d9248021ea --- /dev/null +++ b/packages/components/src/utils/hooks/test/use-controlled-value.js @@ -0,0 +1,101 @@ +/** + * External dependencies + */ +import { fireEvent, render, screen } from '@testing-library/react'; + +/** + * Internal dependencies + */ +import { useControlledValue } from '../use-controlled-value'; + +function Input( props ) { + const [ value, setValue ] = useControlledValue( props ); + return ( + setValue( event.target.value ) } + /> + ); +} + +function getInput() { + return screen.getByRole( 'textbox' ); +} + +describe( 'useControlledValue', () => { + it( 'should use the default value', () => { + render( ); + expect( getInput() ).toHaveValue( 'WordPress.org' ); + } ); + + it( 'should use the default value then switch to the controlled value', () => { + const { rerender } = render( ); + expect( getInput() ).toHaveValue( 'WordPress.org' ); + + rerender( + + ); + expect( getInput() ).toHaveValue( 'Code is Poetry' ); + } ); + + it( 'should not call onChange only when there is no value being passed in', () => { + const onChange = jest.fn(); + render( ); + + expect( getInput() ).toHaveValue( 'WordPress.org' ); + + fireEvent.change( getInput(), { target: { value: 'Code is Poetry' } } ); + + expect( getInput() ).toHaveValue( 'Code is Poetry' ); + expect( onChange ).not.toHaveBeenCalled(); + } ); + + it( 'should call onChange when there is a value passed in', () => { + const onChange = jest.fn(); + const { rerender } = render( + + ); + + expect( getInput() ).toHaveValue( 'Code is Poetry' ); + + fireEvent.change( getInput(), { + target: { value: 'WordPress rocks!' }, + } ); + + rerender( + + ); + + expect( getInput() ).toHaveValue( 'WordPress rocks!' ); + expect( onChange ).toHaveBeenCalledWith( 'WordPress rocks!' ); + } ); + + it( 'should not maintain internal state if no onChange is passed but a value is passed', () => { + const { rerender } = render( ); + + expect( getInput() ).toHaveValue( 'Code is Poetry' ); + + // primarily this proves that the hook doesn't break if no onChange is passed but + // value turns into a controlled state, for example if the value needs to be set + // to a constant in certain conditions but no change listening needs to happen + fireEvent.change( getInput(), { target: { value: 'WordPress.org' } } ); + + // If `value` is passed then we expect the value to be fully controlled + // meaning that the value passed in will always be used even though + // we're managing internal state. + expect( getInput() ).toHaveValue( 'Code is Poetry' ); + + // Next we un-set the value to uncover the internal state which was still maintained + rerender( ); + + expect( getInput() ).toHaveValue( 'WordPress.org' ); + } ); +} ); diff --git a/packages/components/src/utils/hooks/use-controlled-value.ts b/packages/components/src/utils/hooks/use-controlled-value.ts new file mode 100644 index 00000000000000..c0878e75f191cf --- /dev/null +++ b/packages/components/src/utils/hooks/use-controlled-value.ts @@ -0,0 +1,34 @@ +/** + * WordPress dependencies + */ +import { useState } from '@wordpress/element'; + +type Props< T > = { + defaultValue?: T; + value?: T; + onChange?: ( value: T ) => void; +}; + +/** + * Simplified and improved implementation of useControlledState. + * + * @param props + * @param props.defaultValue + * @param props.value + * @param props.onChange + * @return The controlled value and the value setter. + */ +export function useControlledValue< T >( { + defaultValue, + onChange, + value: valueProp, +}: Props< T > ): [ T | undefined, ( value: T ) => void ] { + const hasValue = typeof valueProp !== 'undefined'; + const initialValue = hasValue ? valueProp : defaultValue; + const [ state, setState ] = useState( initialValue ); + const value = hasValue ? valueProp : state; + const setValue = + hasValue && typeof onChange === 'function' ? onChange : setState; + + return [ value, setValue ]; +}