Skip to content

Commit 11cbe4f

Browse files
authored
[LG-5607] feat: useControlled - accept useState Dispatch (#3272)
1 parent 2876f61 commit 11cbe4f

File tree

4 files changed

+87
-5
lines changed

4 files changed

+87
-5
lines changed

.changeset/proud-clouds-hide.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@leafygreen-ui/hooks': minor
3+
---
4+
5+
Updated the useControlled hook argument to utilize the useState Dispatch while ensuring backwards compatibility

packages/hooks/src/useControlled/useControlled.spec.tsx

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,21 @@ describe('packages/hooks/useControlled', () => {
120120
});
121121
expect(result.current.value).toBe('apple');
122122
});
123+
124+
test('updateValue accepts a function for functional updates', () => {
125+
const handler = jest.fn();
126+
const { result } = renderUseControlledHook<number>(5, handler);
127+
result.current.updateValue(prev => prev + 1);
128+
expect(handler).toHaveBeenCalledWith(6);
129+
});
130+
131+
test('functional update uses current controlled value', () => {
132+
const handler = jest.fn();
133+
const { result, rerender } = renderUseControlledHook<number>(10, handler);
134+
rerender(20);
135+
result.current.updateValue(prev => prev * 2);
136+
expect(handler).toHaveBeenCalledWith(40);
137+
});
123138
});
124139

125140
describe('Uncontrolled', () => {
@@ -170,6 +185,49 @@ describe('packages/hooks/useControlled', () => {
170185
expect(result.current.value).toBe('apple');
171186
expect(handler).not.toHaveBeenCalled();
172187
});
188+
189+
test('updateValue accepts a function for functional updates', () => {
190+
const handler = jest.fn();
191+
const { result } = renderUseControlledHook<number>(undefined, handler, 5);
192+
act(() => {
193+
result.current.updateValue(prev => prev + 1);
194+
});
195+
expect(handler).toHaveBeenCalledWith(6);
196+
expect(result.current.value).toBe(6);
197+
});
198+
199+
test('functional update uses current uncontrolled value', () => {
200+
const handler = jest.fn();
201+
const { result } = renderUseControlledHook<number>(
202+
undefined,
203+
handler,
204+
10,
205+
);
206+
act(() => {
207+
result.current.updateValue(prev => prev * 2);
208+
});
209+
expect(handler).toHaveBeenCalledWith(20);
210+
expect(result.current.value).toBe(20);
211+
});
212+
213+
test('functional update avoids stale closures', () => {
214+
const handler = jest.fn();
215+
const { result } = renderUseControlledHook<number>(undefined, handler, 0);
216+
217+
// Simulate multiple updates that might capture stale values
218+
act(() => {
219+
result.current.updateValue(prev => prev + 1);
220+
result.current.updateValue(prev => prev + 1);
221+
result.current.updateValue(prev => prev + 1);
222+
});
223+
224+
// With functional updates, each update should use the latest value
225+
expect(handler).toHaveBeenCalledTimes(3);
226+
expect(handler).toHaveBeenNthCalledWith(1, 1);
227+
expect(handler).toHaveBeenNthCalledWith(2, 2);
228+
expect(handler).toHaveBeenNthCalledWith(3, 3);
229+
expect(result.current.value).toBe(3);
230+
});
173231
});
174232

175233
describe('Within test component', () => {

packages/hooks/src/useControlled/useControlled.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,12 +48,30 @@ export const useControlled = <T extends any = undefined>(
4848
* If the component is controlled, it will not update the controlled value.
4949
*
5050
* onChange callback is called if provided.
51+
* Accepts either a direct value or a function that receives the previous value.
5152
*/
52-
const updateValue = (newVal: T) => {
53+
const updateValue = (newVal: React.SetStateAction<T>) => {
5354
if (!isControlled) {
54-
setUncontrolledValue(newVal);
55+
// In uncontrolled mode, use the state setter's functional update
56+
// to ensure we always get the latest value
57+
if (typeof newVal === 'function') {
58+
setUncontrolledValue(prev => {
59+
const resolvedValue = (newVal as (prev: T) => T)(prev);
60+
onChange?.(resolvedValue);
61+
return resolvedValue;
62+
});
63+
} else {
64+
setUncontrolledValue(newVal);
65+
onChange?.(newVal);
66+
}
67+
} else {
68+
// In controlled mode, resolve the value using the current controlled value
69+
const resolvedValue =
70+
typeof newVal === 'function'
71+
? (newVal as (prev: T) => T)(value)
72+
: newVal;
73+
onChange?.(resolvedValue);
5574
}
56-
onChange?.(newVal);
5775
};
5876

5977
/**

packages/hooks/src/useControlled/useControlled.types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,10 @@ export interface ControlledReturnObject<T extends any> {
77

88
/**
99
* Either updates the uncontrolled value,
10-
* or calls the provided `onChange` callback
10+
* or calls the provided `onChange` callback.
11+
* Accepts either a direct value or a function that receives the previous value.
1112
*/
12-
updateValue: (newVal: T) => void;
13+
updateValue: (newVal: React.SetStateAction<T>) => void;
1314

1415
/**
1516
* A setter for the internal value.

0 commit comments

Comments
 (0)