Skip to content

Commit 579e8c7

Browse files
authored
[LG-5553] feat(hooks): add useControlled hook for generic controlled/uncontrolled state management (#3153)
* feat(hooks): add useControlledInputValue hook with tests * refactor(hooks): rename useControlledValue to useControlledInputValue and update tests accordingly * refactor: replace useControlledValue with useControlledInputValue across multiple components * refactor(hooks): restore useControlledValue export and clean up imports in useControlledInputValue * refactor(hooks): update type definitions and imports in useControlledInputValue and useControlledValue * refactor(hooks): update type definitions * doc(hook): add changesets * docs(hooks): minor to major * refactor(hooks): remove useControlledInputValue and update components to use useControlledValue * test(hooks): file useControlled spec file name * refactor(hooks): remove console warning from useControlled and add to useControlledValue * refactor(hooks): add console warning for useControlled and remove from useControlledValue * refactor(hooks): reorder imports in useControlled for consistency * refactor(hooks): update console log to be more generic
1 parent bda06c5 commit 579e8c7

File tree

9 files changed

+428
-291
lines changed

9 files changed

+428
-291
lines changed

.changeset/new-dryers-invite.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@leafygreen-ui/hooks': minor
3+
---
4+
5+
- Creates `useControlled` hook. This hook is a more generic version of `useControlledValue` that can be used for any component.
6+
- Refactors `useControlledValue` to use `useControlled` under the hood.

packages/hooks/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export { useAutoScroll } from './useAutoScroll';
22
export { default as useAvailableSpace } from './useAvailableSpace';
33
export { useBackdropClick } from './useBackdropClick';
4+
export { useControlled } from './useControlled';
45
export { useControlledValue } from './useControlledValue';
56
export { type DynamicRefGetter, useDynamicRefs } from './useDynamicRefs';
67
export { default as useEscapeKey } from './useEscapeKey';
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export { useControlled } from './useControlled';
Lines changed: 275 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,275 @@
1+
import React, { ChangeEventHandler } from 'react';
2+
import { render } from '@testing-library/react';
3+
import userEvent from '@testing-library/user-event';
4+
5+
import { act, renderHook } from '@leafygreen-ui/testing-lib';
6+
7+
import { useControlled } from './useControlled';
8+
9+
const errorSpy = jest.spyOn(console, 'error');
10+
11+
const renderUseControlledHook = <T extends any>(
12+
...[valueProp, callback, initial]: Parameters<typeof useControlled<T>>
13+
) => {
14+
const result = renderHook(v => useControlled(v, callback, initial), {
15+
initialProps: valueProp,
16+
});
17+
18+
return result;
19+
};
20+
21+
describe('packages/hooks/useControlled', () => {
22+
beforeEach(() => {
23+
errorSpy.mockImplementation(() => {});
24+
});
25+
26+
afterEach(() => {
27+
errorSpy.mockReset();
28+
});
29+
30+
test('rendering without any arguments sets hook to uncontrolled', () => {
31+
const { result } = renderUseControlledHook();
32+
expect(result.current.isControlled).toEqual(false);
33+
});
34+
35+
describe('accepts various value types', () => {
36+
test('accepts number values', () => {
37+
const { result } = renderUseControlledHook(5);
38+
expect(result.current.value).toBe(5);
39+
});
40+
41+
test('accepts boolean values', () => {
42+
const { result } = renderUseControlledHook(false);
43+
expect(result.current.value).toBe(false);
44+
});
45+
46+
test('accepts array values', () => {
47+
const arr = ['foo', 'bar'];
48+
const { result } = renderUseControlledHook(arr);
49+
expect(result.current.value).toBe(arr);
50+
});
51+
52+
test('accepts object values', () => {
53+
const obj = { foo: 'foo', bar: 'bar' };
54+
const { result } = renderUseControlledHook(obj);
55+
expect(result.current.value).toBe(obj);
56+
});
57+
58+
test('accepts date values', () => {
59+
const date = new Date('2023-08-23');
60+
const { result } = renderUseControlledHook(date);
61+
expect(result.current.value).toBe(date);
62+
});
63+
64+
test('accepts multiple/union types', () => {
65+
const { result, rerender } = renderUseControlledHook<string | number>(5);
66+
expect(result.current.value).toBe(5);
67+
rerender('foo');
68+
expect(result.current.value).toBe('foo');
69+
});
70+
});
71+
72+
describe('Controlled', () => {
73+
test('rendering with a value sets value and isControlled', () => {
74+
const { result } = renderUseControlledHook('apple');
75+
expect(result.current.isControlled).toBe(true);
76+
expect(result.current.value).toBe('apple');
77+
});
78+
79+
test('rerendering with a new value changes the value', () => {
80+
const { rerender, result } = renderUseControlledHook('apple');
81+
expect(result.current.value).toBe('apple');
82+
rerender('banana');
83+
expect(result.current.value).toBe('banana');
84+
});
85+
86+
test('provided handler is called within `updateValue`', () => {
87+
const handler = jest.fn();
88+
const { result } = renderUseControlledHook<string>('apple', handler);
89+
result.current.updateValue('banana');
90+
expect(handler).toHaveBeenCalledWith('banana');
91+
});
92+
93+
test('hook value does not change when `updateValue` is called', () => {
94+
const { result } = renderUseControlledHook<string>('apple');
95+
result.current.updateValue('banana');
96+
// value doesn't change unless we explicitly change it
97+
expect(result.current.value).toBe('apple');
98+
});
99+
100+
test('setting value to undefined should keep the component controlled', () => {
101+
const { rerender, result } = renderUseControlledHook('apple');
102+
expect(result.current.isControlled).toBe(true);
103+
act(() => rerender(undefined));
104+
expect(result.current.isControlled).toBe(true);
105+
});
106+
107+
test('initial value is ignored when controlled', () => {
108+
const { result } = renderUseControlledHook<string>(
109+
'apple',
110+
() => {},
111+
'banana',
112+
);
113+
expect(result.current.value).toBe('apple');
114+
});
115+
116+
test('setUncontrolledValue does nothing for controlled components', () => {
117+
const { result } = renderUseControlledHook('apple');
118+
act(() => {
119+
result.current.setUncontrolledValue('banana');
120+
});
121+
expect(result.current.value).toBe('apple');
122+
});
123+
});
124+
125+
describe('Uncontrolled', () => {
126+
test('calling without a value sets value to `initialValue`', () => {
127+
const {
128+
result: { current },
129+
} = renderUseControlledHook(undefined, () => {}, 'apple');
130+
131+
expect(current.isControlled).toBe(false);
132+
expect(current.value).toBe('apple');
133+
});
134+
135+
test('provided handler is called within `updateValue`', () => {
136+
const handler = jest.fn();
137+
const {
138+
result: { current },
139+
} = renderUseControlledHook(undefined, handler);
140+
141+
current.updateValue('apple');
142+
expect(handler).toHaveBeenCalledWith('apple');
143+
});
144+
145+
test('updateValue updates the value', () => {
146+
const { result, rerender } = renderUseControlledHook<string>(undefined);
147+
result.current.updateValue('banana');
148+
rerender();
149+
expect(result.current.value).toBe('banana');
150+
});
151+
152+
test('rerendering from initial undefined does not set value and isControlled', async () => {
153+
const { rerender, result } = renderUseControlledHook();
154+
rerender('apple');
155+
expect(result.current.isControlled).toBe(false);
156+
expect(result.current.value).toBeUndefined();
157+
});
158+
159+
test('setUncontrolledValue updates value', () => {
160+
const handler = jest.fn();
161+
const { result } = renderUseControlledHook<string>(
162+
undefined,
163+
handler,
164+
'',
165+
);
166+
167+
act(() => {
168+
result.current.setUncontrolledValue('apple');
169+
});
170+
expect(result.current.value).toBe('apple');
171+
expect(handler).not.toHaveBeenCalled();
172+
});
173+
});
174+
175+
describe('Within test component', () => {
176+
const TestComponent = ({
177+
valueProp,
178+
handlerProp,
179+
}: {
180+
valueProp?: string;
181+
handlerProp?: (val?: string) => void;
182+
}) => {
183+
const initialVal = '';
184+
185+
const { value, updateValue } = useControlled(
186+
valueProp,
187+
handlerProp,
188+
initialVal,
189+
);
190+
191+
const handleChange: ChangeEventHandler<HTMLInputElement> = e => {
192+
updateValue(e.target.value);
193+
};
194+
195+
return (
196+
<>
197+
<input
198+
data-testid="test-input"
199+
value={value}
200+
onChange={handleChange}
201+
/>
202+
<button
203+
data-testid="test-button"
204+
onClick={() => updateValue('carrot')}
205+
/>
206+
</>
207+
);
208+
};
209+
210+
describe('Controlled', () => {
211+
test('initially renders with a value', () => {
212+
const result = render(<TestComponent valueProp="apple" />);
213+
const input = result.getByTestId('test-input');
214+
expect(input).toHaveValue('apple');
215+
});
216+
217+
test('responds to value changes', () => {
218+
const result = render(<TestComponent valueProp="apple" />);
219+
const input = result.getByTestId('test-input');
220+
result.rerender(<TestComponent valueProp="banana" />);
221+
expect(input).toHaveValue('banana');
222+
});
223+
224+
test('user interaction triggers handler', () => {
225+
const handler = jest.fn();
226+
const result = render(
227+
<TestComponent valueProp="apple" handlerProp={handler} />,
228+
);
229+
const input = result.getByTestId('test-input');
230+
userEvent.type(input, 'b');
231+
expect(handler).toHaveBeenCalledWith(expect.stringContaining('b'));
232+
});
233+
234+
test('user interaction does not change the element value', () => {
235+
const result = render(<TestComponent valueProp="apple" />);
236+
const input = result.getByTestId('test-input');
237+
userEvent.type(input, 'b');
238+
expect(input).toHaveValue('apple');
239+
});
240+
});
241+
242+
describe('Uncontrolled', () => {
243+
test('initially renders without a value', () => {
244+
const result = render(<TestComponent />);
245+
const input = result.getByTestId('test-input');
246+
expect(input).toHaveValue('');
247+
expect(errorSpy).not.toHaveBeenCalled();
248+
});
249+
250+
test('user interaction triggers handler', () => {
251+
const handler = jest.fn();
252+
const result = render(<TestComponent handlerProp={handler} />);
253+
const input = result.getByTestId('test-input');
254+
userEvent.type(input, 'b');
255+
expect(handler).toHaveBeenCalled();
256+
});
257+
258+
test('user interaction changes the element value', () => {
259+
const handler = jest.fn();
260+
const result = render(<TestComponent handlerProp={handler} />);
261+
const input = result.getByTestId('test-input');
262+
userEvent.type(input, 'banana');
263+
expect(input).toHaveValue('banana');
264+
});
265+
266+
test('clicking the button updates the value', () => {
267+
const result = render(<TestComponent />);
268+
const input = result.getByTestId('test-input');
269+
const button = result.getByTestId('test-button');
270+
userEvent.click(button);
271+
expect(input).toHaveValue('carrot');
272+
});
273+
});
274+
});
275+
});
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import { useEffect, useMemo, useState } from 'react';
2+
import isUndefined from 'lodash/isUndefined';
3+
4+
import { consoleOnce } from '@leafygreen-ui/lib';
5+
6+
import { ControlledReturnObject } from './useControlled.types';
7+
8+
/**
9+
* A hook that enables a component to be both controlled or uncontrolled.
10+
*
11+
* Returns a {@link ControlledReturnObject}
12+
*/
13+
export const useControlled = <T extends any>(
14+
controlledValue?: T,
15+
onChange?: (val?: T) => void,
16+
initialValue?: T,
17+
): ControlledReturnObject<T | undefined> => {
18+
/**
19+
* isControlled should only be computed once
20+
*/
21+
// eslint-disable-next-line react-hooks/exhaustive-deps
22+
const isControlled = useMemo(() => !isUndefined(controlledValue), []);
23+
24+
/**
25+
* Keep track of the uncontrolled value state internally
26+
*/
27+
const [uncontrolledValue, setUncontrolledValue] = useState<T | undefined>(
28+
initialValue,
29+
);
30+
31+
/**
32+
* The returned value.
33+
* If the component is uncontrolled, it will return the internal value.
34+
* If the component is controlled, it will return the controlled value.
35+
*/
36+
const value = useMemo(
37+
() => (isControlled ? controlledValue : uncontrolledValue),
38+
[isControlled, uncontrolledValue, controlledValue],
39+
);
40+
41+
/**
42+
* Updates the value of the component.
43+
* If the component is uncontrolled, it will update the internal value.
44+
* If the component is controlled, it will not update the controlled value.
45+
*
46+
* onChange callback is called if provided.
47+
*/
48+
const updateValue = (newVal: T | undefined) => {
49+
if (!isControlled) {
50+
setUncontrolledValue(newVal);
51+
}
52+
onChange?.(newVal);
53+
};
54+
55+
/**
56+
* Log a warning if neither controlled value or initialValue is provided
57+
*/
58+
useEffect(() => {
59+
if (isUndefined(controlledValue) && isUndefined(initialValue)) {
60+
consoleOnce.error(
61+
`Warning: \`useControlled\` hook is being used without a value or initialValue. If using an input, this will cause a React warning when an input changes. Please decide between using a controlled or uncontrolled element, and provide either a controlledValue or initialValue to \`useControlled\``,
62+
);
63+
}
64+
}, [controlledValue, initialValue]);
65+
66+
return {
67+
isControlled,
68+
value,
69+
updateValue,
70+
setUncontrolledValue,
71+
};
72+
};
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
export interface ControlledReturnObject<T extends any> {
2+
/** Whether the value is controlled */
3+
isControlled: boolean;
4+
5+
/** The controlled or uncontrolled value */
6+
value: T;
7+
8+
/**
9+
* Either updates the uncontrolled value,
10+
* or calls the provided `onChange` callback
11+
*/
12+
updateValue: (newVal?: T) => void;
13+
14+
/**
15+
* A setter for the internal value.
16+
* Does not change the controlled value if the provided value has not changed.
17+
* Prefer using `updateValue` to programmatically set the value.
18+
* @internal
19+
*/
20+
setUncontrolledValue: React.Dispatch<React.SetStateAction<T>>;
21+
}

0 commit comments

Comments
 (0)