Skip to content

Conversation

chihsuan
Copy link
Member

@chihsuan chihsuan commented Jul 9, 2025

What?

This PR consolidates the useControlledValue hook implementation by enhancing the existing useControlledValue hook and removing the duplicate Calendar-specific version.

Why?

cc @ciampo

Follow-up to #70578 addressing this review comment to reuse the existing useControlledValue hook in the package instead of creating a duplicate implementation.

This change:

  • Eliminates code duplication between the shared and Calendar-specific hooks
  • Provides a single, consistent implementation across all components
  • Reduces maintenance overhead

How?

The key differences between these two useControlledValue hooks are:

1. Type signature:

Value:

  • Utils version: value?: T
  • Calendar version: value?: T | null | undefined

onChange:

  • Utils version: onChange?: (value: T) => void
  • Calendar version: onChange?: (newValue: T, ...args: any[]) => void

2. Value handling:

  • Utils version: const value = hasValue ? valueProp : state;
  • Calendar version: const value = (hasValue ? valueProp : state) ?? undefined;

3. Implementation approach:

  • Utils version: Uses conditional assignment for setValue
  • Calendar version: Uses useCallback for uncontrolled mode with uncontrolledSetValue

Overall, the calendar version is more specialized for date handling (null values) and supports additional arguments in onChange callbacks.

To reuse the existing useControlledValue hook in the Calendar components, I made the following changes to the shared version:

  1. Added support for additional arguments in onChange callbacks (...args).
  2. Used useCallback for uncontrolled mode. This is not strictly necessary for Calendar, but it's a good practice to use it IMO. If there is a concern about introducing this change, we can revert it.

And then, I updated the Calendar components:

  • Removed the duplicate Calendar-specific implementation
  • Updated Calendar components to import from the shared hook location
  • Converted null values to undefined for selected value in Calendar components to ensure correct behavior.

Testing Instructions

Note: Existing Calendar component tests have covered controlled/uncontrolled modes, onChange callbacks with additional arguments, and state transitions.

  1. Run npm run storybook:dev
  2. Go to http://localhost:50240/?path=/story/components-selection-input-time-date-datecalendar--default
  3. Verify Calendar components (DateCalendar, DateRangeCalendar) continue to work as expected
  4. Test both controlled and uncontrolled modes in Calendar components by setting defaultSelected and selected props.

Testing Instructions for Keyboard

@chihsuan chihsuan requested a review from ajitbohra as a code owner July 9, 2025 08:18
Copy link

github-actions bot commented Jul 9, 2025

The following accounts have interacted with this PR and/or linked issues. I will continue to update these lists as activity occurs. You can also manually ask me to refresh this list by adding the props-bot label.

If you're merging code through a pull request on GitHub, copy and paste the following into the bottom of the merge commit message.

Co-authored-by: chihsuan <chihsuan@git.wordpress.org>
Co-authored-by: ciampo <mciampini@git.wordpress.org>

To understand the WordPress project's expectations around crediting contributors, please review the Contributor Attribution page in the Core Handbook.

defaultSelected: new Date( 1751472000000 ),
selected: new Date( 1751558400000 ),
},
};
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed TS errors. I'm not sure why these TS errors weren't caught in the previous PR.

interface SingleProps {
/** The selected date. */
selected?: Date | undefined | null;
/** Event handler when a day is selected. */
onSelect?: OnSelectHandler< Date | undefined >;
/** The default selected date (for uncontrolled usage). */
defaultSelected?: Date;
}

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which errors? AFAIK it's fine for DateCalendar not to have those props defined. Besides, it wouldn't make sense to define both defaultSelected and selected at the same time — one is for uncontrolled usage, the other one for controlled usage.

We should be able to undo these changes without causing TS errors

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right! Previously, I encountered TS errors that stopped me from committing the changes. But now, I don't see any errors. I'll undo the changes.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reverted e66276b

@@ -48,7 +51,7 @@ export const DateCalendar = ( {
{ ...props }
mode="single"
numberOfMonths={ clampNumberOfMonths( numberOfMonths ) }
selected={ selected }
selected={ selected ?? undefined }
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously, the calendar's useControlledValue hook internally converted the empty value to undefined. I don't think this should be added to the utils useControlledValue, so I updated the calendar's implementation to handle this case.

const value = ( hasValue ? valueProp : state ) ?? undefined;

@chihsuan chihsuan force-pushed the enhance/calendar-reuse-use-controlled-value branch from 66c8e75 to 24b85da Compare July 9, 2025 08:30
chihsuan added 4 commits July 10, 2025 10:31
- Updated the `onChange` prop to accept additional arguments for better flexibility.
- Introduced a new `uncontrolledSetValue` function using `useCallback` to streamline state updates and onChange calls in uncontrolled mode.
- Improved code clarity by separating controlled and uncontrolled mode logic.

This change enhances the usability of the hook in various scenarios.
…hook

- Updated imports of `useControlledValue` in `DateCalendar` and `DateRangeCalendar` components to point to the new location in `../../utils/hooks`.
- Enhanced type handling for the `selected` state to accommodate `null` values, ensuring better compatibility with the updated hook.
- Adjusted the `selected` prop to default to `undefined` when `null`, improving the handling of unselected states.
- Modified the `Default` story for `DateCalendar` to include example arguments for `defaultSelected` and `selected`, enhancing story clarity.
@chihsuan chihsuan force-pushed the enhance/calendar-reuse-use-controlled-value branch from 24b85da to b9cc963 Compare July 10, 2025 02:31
@t-hamano t-hamano added [Type] Code Quality Issues or PRs that relate to code quality [Package] Components /packages/components labels Jul 10, 2025
@ciampo ciampo requested review from mirka and ciampo July 10, 2025 16:53
@ciampo ciampo assigned ciampo and chihsuan and unassigned ciampo Jul 10, 2025
@ciampo
Copy link
Contributor

ciampo commented Jul 10, 2025

Thank you, @chihsuan ! I'll take a look in the upcoming days :)

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for working on this! I suggested a few tweaks, but the overall approach is good :)

defaultSelected: new Date( 1751472000000 ),
selected: new Date( 1751558400000 ),
},
};
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Which errors? AFAIK it's fine for DateCalendar not to have those props defined. Besides, it wouldn't make sense to define both defaultSelected and selected at the same time — one is for uncontrolled usage, the other one for controlled usage.

We should be able to undo these changes without causing TS errors

Comment on lines 38 to 43
const [ selected, setSelected ] = useControlledValue<
Date | undefined | null
>( {
defaultValue: defaultSelected,
value: selectedProp,
// @ts-ignore: The useControlledValue expects onChange value parameter type is the same as the value parameter, but in our case, onSelect always receives DateRange | undefined.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly to how we now handle the fallback to ?? undefined when passing the selected value to DayPicker, we can write an internal onChange callback to do the same conversion and avoid the @ts-ignore

diff --git i/packages/components/src/calendar/date-calendar/index.tsx w/packages/components/src/calendar/date-calendar/index.tsx
index a77da53060..f70807d4d3 100644
--- i/packages/components/src/calendar/date-calendar/index.tsx
+++ w/packages/components/src/calendar/date-calendar/index.tsx
@@ -3,6 +3,10 @@
  */
 import { DayPicker } from 'react-day-picker';
 import { enUS } from 'react-day-picker/locale';
+/**
+ * WordPress dependencies
+ */
+import { useCallback } from '@wordpress/element';
 /**
  * Internal dependencies
  */
@@ -10,7 +14,7 @@ import { COMMON_PROPS } from '../utils/constants';
 import { clampNumberOfMonths } from '../utils/misc';
 import { useControlledValue } from '../../utils/hooks';
 import { useLocalizationProps } from '../utils/use-localization-props';
-import type { DateCalendarProps } from '../types';
+import type { DateCalendarProps, OnSelectHandler } from '../types';
 
 /**
  * `DateCalendar` is a React component that provides a customizable calendar
@@ -35,14 +39,21 @@ export const DateCalendar = ( {
 		mode: 'single',
 	} );
 
-	const [ selected, setSelected ] = useControlledValue<
-		Date | undefined | null
-	>( {
-		defaultValue: defaultSelected,
-		value: selectedProp,
-		// @ts-ignore: The useControlledValue expects onChange value parameter type is the same as the value parameter, but in our case, onSelect always receives DateRange | undefined.
-		onChange: onSelect,
-	} );
+	const onChange: OnSelectHandler< typeof selectedProp > = useCallback(
+		( selected, triggerDate, modifiers, e ) => {
+			// Convert internal `null` to `undefined` for the public event handler.
+			onSelect?.( selected ?? undefined, triggerDate, modifiers, e );
+		},
+		[ onSelect ]
+	);
+
+	const [ selected, setSelected ] = useControlledValue< typeof selectedProp >(
+		{
+			defaultValue: defaultSelected,
+			value: selectedProp,
+			onChange,
+		}
+	);
 
 	return (
 		<DayPicker
diff --git i/packages/components/src/calendar/date-range-calendar/index.tsx w/packages/components/src/calendar/date-range-calendar/index.tsx
index 0d4871103b..2b40e3c0a4 100644
--- i/packages/components/src/calendar/date-range-calendar/index.tsx
+++ w/packages/components/src/calendar/date-range-calendar/index.tsx
@@ -7,7 +7,7 @@ import { enUS } from 'react-day-picker/locale';
 /**
  * WordPress dependencies
  */
-import { useMemo, useState } from '@wordpress/element';
+import { useMemo, useState, useCallback } from '@wordpress/element';
 /**
  * Internal dependencies
  */
@@ -15,7 +15,11 @@ import { COMMON_PROPS, MODIFIER_CLASSNAMES } from '../utils/constants';
 import { clampNumberOfMonths } from '../utils/misc';
 import { useControlledValue } from '../../utils/hooks';
 import { useLocalizationProps } from '../utils/use-localization-props';
-import type { DateRangeCalendarProps, DateRange } from '../types';
+import type {
+	DateRangeCalendarProps,
+	DateRange,
+	OnSelectHandler,
+} from '../types';
 
 export function usePreviewRange( {
 	selected,
@@ -151,13 +155,20 @@ export const DateRangeCalendar = ( {
 		mode: 'range',
 	} );
 
+	const onChange: OnSelectHandler< typeof selectedProp > = useCallback(
+		( selected, triggerDate, modifiers, e ) => {
+			// Convert internal `null` to `undefined` for the public event handler.
+			onSelect?.( selected ?? undefined, triggerDate, modifiers, e );
+		},
+		[ onSelect ]
+	);
+
 	const [ selected, setSelected ] = useControlledValue<
 		DateRange | undefined | null
 	>( {
 		defaultValue: defaultSelected,
 		value: selectedProp,
-		// @ts-ignore: The useControlledValue expects onChange value parameter type is the same as the value parameter, but in our case, onSelect always receives DateRange | undefined.
-		onChange: onSelect,
+		onChange,
 	} );
 
 	const [ hoveredDate, setHoveredDate ] = useState< Date | undefined >(
diff --git i/packages/components/src/calendar/types.ts w/packages/components/src/calendar/types.ts
index dc4bb72d5b..931556aaf5 100644
--- i/packages/components/src/calendar/types.ts
+++ w/packages/components/src/calendar/types.ts
@@ -153,7 +153,7 @@ type DayOfWeek = {
  * @param {Modifiers}                              modifiers   - The modifiers associated with the event.
  * @param {React.MouseEvent | React.KeyboardEvent} e           - The event object.
  */
-type OnSelectHandler< T > = (
+export type OnSelectHandler< T > = (
 	selected: T,
 	triggerDate: Date,
 	modifiers: Modifiers,
diff --git i/packages/components/src/utils/hooks/use-controlled-value.ts w/packages/components/src/utils/hooks/use-controlled-value.ts
index b43f670332..725283736f 100644
--- i/packages/components/src/utils/hooks/use-controlled-value.ts
+++ w/packages/components/src/utils/hooks/use-controlled-value.ts
@@ -48,5 +48,5 @@ export function useControlledValue< T >( {
 		setValue = setState;
 	}
 
-	return [ value, setValue as typeof setState ] as const;
+	return [ value, setValue ] as const;
 }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for the detailed feedback and the comprehensive diff!

I initially went with @ts-ignore to keep the changes minimal, but after considering your approach, I agree that the explicit onChange callback is much better for several.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated as suggested in 8db0b82

@chihsuan chihsuan requested a review from ciampo July 16, 2025 05:28
@@ -48,7 +48,9 @@ function UnforwardedToggleGroupControlAsButtonGroup(
const { value, defaultValue } =
useComputeControlledOrUncontrolledValue( valueProp );

const [ selectedValue, setSelectedValue ] = useControlledValue( {
const [ selectedValue, setSelectedValue ] = useControlledValue<
typeof value
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We removed as typeof setState from the useControlledValue hook, which caused a TS error. This was resolved by explicitly specifying the generic type for useControlledValue.

Screenshot 2025-07-16 at 13 31 12

https://github.com/WordPress/gutenberg/pull/70655/files#diff-4df4aa449ece25eb703b298c9bfc3cdaae55552ccd2040f629efc4caa1bf9f8fL43

@chihsuan
Copy link
Member Author

@ciampo Thank you for the review! I've addressed all the review comments. All changes are ready for another review. 🙏

},
[ onSelect ]
);

const [ selected, setSelected ] = useControlledValue<
DateRange | undefined | null
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: could this be also changed to typeof selectedProp ?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As a further improvement, it would be nice to make the useControlledValue hook infer the T type from the received arguments, but that's definitely something we don't have to work on today.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: could this be also changed to typeof selectedProp ?

Abosolutely, changed it to typeof selectedProp in 8c3977f. 🙌

Copy link
Contributor

@ciampo ciampo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM 🚀 We can merge once this last comment is addressed

@chihsuan
Copy link
Member Author

We can merge once #70655 (comment) is addressed

Thank you, @ciampo! I've made the changes. Since I don't have merge permissions, could you or someone else please handle the merge? Much appreciated! 🙏

@ciampo ciampo enabled auto-merge (squash) July 18, 2025 09:14
@ciampo ciampo merged commit a3df6d4 into WordPress:trunk Jul 18, 2025
59 checks passed
@github-actions github-actions bot added this to the Gutenberg 21.3 milestone Jul 18, 2025
USERSATOSHI pushed a commit to USERSATOSHI/gutenberg that referenced this pull request Jul 23, 2025
…onents (WordPress#70655)

* Refactor `useControlledValue` hook to enhance onChange handling

- Updated the `onChange` prop to accept additional arguments for better flexibility.
- Introduced a new `uncontrolledSetValue` function using `useCallback` to streamline state updates and onChange calls in uncontrolled mode.
- Improved code clarity by separating controlled and uncontrolled mode logic.

This change enhances the usability of the hook in various scenarios.

* Refactor calendar components to utilize updated `useControlledValue` hook

- Updated imports of `useControlledValue` in `DateCalendar` and `DateRangeCalendar` components to point to the new location in `../../utils/hooks`.
- Enhanced type handling for the `selected` state to accommodate `null` values, ensuring better compatibility with the updated hook.
- Adjusted the `selected` prop to default to `undefined` when `null`, improving the handling of unselected states.
- Modified the `Default` story for `DateCalendar` to include example arguments for `defaultSelected` and `selected`, enhancing story clarity.

* Improve docs

* Add changelog

* Remove the args from DateCalendar story

* Address review feedback: Replace @ts-ignore with proper onChange callbacks

* Update useControlledValue type to match selectedProp
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
[Package] Components /packages/components [Type] Code Quality Issues or PRs that relate to code quality
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants