Skip to content

Commit 30a4c46

Browse files
Add useControlledValue (#33039)
* Add useControlledValue * Add unit tests * Remove usage of renderHook * Allow null as a value
1 parent b3c43db commit 30a4c46

File tree

3 files changed

+136
-0
lines changed

3 files changed

+136
-0
lines changed
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { default as useControlledState } from './use-controlled-state';
22
export { default as useJumpStep } from './use-jump-step';
33
export { default as useUpdateEffect } from './use-update-effect';
4+
export { useControlledValue } from './use-controlled-value';
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
/**
2+
* External dependencies
3+
*/
4+
import { fireEvent, render, screen } from '@testing-library/react';
5+
6+
/**
7+
* Internal dependencies
8+
*/
9+
import { useControlledValue } from '../use-controlled-value';
10+
11+
function Input( props ) {
12+
const [ value, setValue ] = useControlledValue( props );
13+
return (
14+
<input
15+
value={ value }
16+
onChange={ ( event ) => setValue( event.target.value ) }
17+
/>
18+
);
19+
}
20+
21+
function getInput() {
22+
return screen.getByRole( 'textbox' );
23+
}
24+
25+
describe( 'useControlledValue', () => {
26+
it( 'should use the default value', () => {
27+
render( <Input defaultValue="WordPress.org" /> );
28+
expect( getInput() ).toHaveValue( 'WordPress.org' );
29+
} );
30+
31+
it( 'should use the default value then switch to the controlled value', () => {
32+
const { rerender } = render( <Input defaultValue="WordPress.org" /> );
33+
expect( getInput() ).toHaveValue( 'WordPress.org' );
34+
35+
rerender(
36+
<Input defaultValue="WordPress.org" value="Code is Poetry" />
37+
);
38+
expect( getInput() ).toHaveValue( 'Code is Poetry' );
39+
} );
40+
41+
it( 'should not call onChange only when there is no value being passed in', () => {
42+
const onChange = jest.fn();
43+
render( <Input defaultValue="WordPress.org" onChange={ onChange } /> );
44+
45+
expect( getInput() ).toHaveValue( 'WordPress.org' );
46+
47+
fireEvent.change( getInput(), { target: { value: 'Code is Poetry' } } );
48+
49+
expect( getInput() ).toHaveValue( 'Code is Poetry' );
50+
expect( onChange ).not.toHaveBeenCalled();
51+
} );
52+
53+
it( 'should call onChange when there is a value passed in', () => {
54+
const onChange = jest.fn();
55+
const { rerender } = render(
56+
<Input
57+
defaultValue="WordPress.org"
58+
value="Code is Poetry"
59+
onChange={ onChange }
60+
/>
61+
);
62+
63+
expect( getInput() ).toHaveValue( 'Code is Poetry' );
64+
65+
fireEvent.change( getInput(), {
66+
target: { value: 'WordPress rocks!' },
67+
} );
68+
69+
rerender(
70+
<Input
71+
defaultValue="WordPress.org"
72+
value="WordPress rocks!"
73+
onChange={ onChange }
74+
/>
75+
);
76+
77+
expect( getInput() ).toHaveValue( 'WordPress rocks!' );
78+
expect( onChange ).toHaveBeenCalledWith( 'WordPress rocks!' );
79+
} );
80+
81+
it( 'should not maintain internal state if no onChange is passed but a value is passed', () => {
82+
const { rerender } = render( <Input value="Code is Poetry" /> );
83+
84+
expect( getInput() ).toHaveValue( 'Code is Poetry' );
85+
86+
// primarily this proves that the hook doesn't break if no onChange is passed but
87+
// value turns into a controlled state, for example if the value needs to be set
88+
// to a constant in certain conditions but no change listening needs to happen
89+
fireEvent.change( getInput(), { target: { value: 'WordPress.org' } } );
90+
91+
// If `value` is passed then we expect the value to be fully controlled
92+
// meaning that the value passed in will always be used even though
93+
// we're managing internal state.
94+
expect( getInput() ).toHaveValue( 'Code is Poetry' );
95+
96+
// Next we un-set the value to uncover the internal state which was still maintained
97+
rerender( <Input /> );
98+
99+
expect( getInput() ).toHaveValue( 'WordPress.org' );
100+
} );
101+
} );
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
/**
2+
* WordPress dependencies
3+
*/
4+
import { useState } from '@wordpress/element';
5+
6+
type Props< T > = {
7+
defaultValue?: T;
8+
value?: T;
9+
onChange?: ( value: T ) => void;
10+
};
11+
12+
/**
13+
* Simplified and improved implementation of useControlledState.
14+
*
15+
* @param props
16+
* @param props.defaultValue
17+
* @param props.value
18+
* @param props.onChange
19+
* @return The controlled value and the value setter.
20+
*/
21+
export function useControlledValue< T >( {
22+
defaultValue,
23+
onChange,
24+
value: valueProp,
25+
}: Props< T > ): [ T | undefined, ( value: T ) => void ] {
26+
const hasValue = typeof valueProp !== 'undefined';
27+
const initialValue = hasValue ? valueProp : defaultValue;
28+
const [ state, setState ] = useState( initialValue );
29+
const value = hasValue ? valueProp : state;
30+
const setValue =
31+
hasValue && typeof onChange === 'function' ? onChange : setState;
32+
33+
return [ value, setValue ];
34+
}

0 commit comments

Comments
 (0)