I have a component that looks like this:
export const DatePickerInput = ({
label,
onChange,
defaultSelected,
fromDate,
placeholder,
hideLabel,
className,
valuesInitialized,
setValueInitialized,
}: DatePickerModalProps) => {
console.log(defaultSelected)
// inputProps returns "onChange" | "onFocus" | "onBlur" | "value"
const { datepickerProps, inputProps, setSelected, reset } = UNSAFE_useDatepicker({
onDateChange: (date) => {
onChange(date);
},
fromDate,
defaultSelected,
});
return (
<UNSAFE_DatePicker {...datepickerProps}>
<UNSAFE_DatePicker.Input
{...inputProps}
className={className}
hideLabel={hideLabel}
placeholder={placeholder}
label={label}
size="small"
/>
</UNSAFE_DatePicker>
);
};
What I don't understand is why when a prop is changed, and in this case prop defaultSelected
goes from undefined
to some value once it is fetched from api endpoint. What I wonder is since React rerenders when prop changes, how come is custom hook UNSAFE_useDatepicker
not called with an updated value? Because I can see that the DatePicker has been called with a new value, but UNSAFE_useDatepicker
hook hasn't returned a new value.
This is how the UNSAFE_useDatepicker
hook looks like:
import differenceInCalendarDays from "date-fns/differenceInCalendarDays";
import isWeekend from "date-fns/isWeekend";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { DayClickEventHandler, isMatch } from "react-day-picker";
import { DateInputProps } from "../DateInput";
import { DatePickerProps } from "../datepicker/DatePicker";
import {
formatDateForInput,
getLocaleFromString,
isValidDate,
parseDate,
} from "../utils";
export interface UseDatepickerOptions
extends Pick<
DatePickerProps,
| "locale"
| "fromDate"
| "toDate"
| "today"
| "toDate"
| "fromDate"
| "toDate"
| "disabled"
| "disableWeekends"
> {
/**
* The initially selected Date
*/
defaultSelected?: Date;
/**
* Default shown month
*/
defaultMonth?: Date;
/**
* Make selection of Date required
*/
required?: boolean;
/**
* Callback for changed state
*/
onDateChange?: (val?: Date) => void;
/**
* Input-format
* @default "dd.MM.yyyy"
*/
inputFormat?: string;
/**
* validation-callback
*/
onValidate?: (val: DateValidationT) => void;
/**
* Allows input of with 'yy' year format.
* @default true
* @Note Decision between 20th and 21st century is based on before(todays year - 80) ? 21st : 20th.
* In 2023 this equals to 1943 - 2042
*/
allowTwoDigitYear?: boolean;
}
interface UseDatepickerValue {
/**
* Use: <DatePicker {...datepickerProps}/>
*/
datepickerProps: DatePickerProps;
/**
* Use: <DatePicker.Input {...inputProps}/>
*/
inputProps: Pick<
DateInputProps,
"onChange" | "onFocus" | "onBlur" | "value"
> & { ref: React.RefObject<HTMLInputElement> };
/**
* Resets all states (callback)
*/
reset: () => void;
/**
* Currently selected date
* Up to user to validate date
*/
selectedDay?: Date;
/**
* Manually override currently selected day
*/
setSelected: (date?: Date) => void;
}
export type DateValidationT = {
isDisabled: boolean;
isWeekend: boolean;
isEmpty: boolean;
isInvalid: boolean;
isValidDate: boolean;
isBefore: boolean;
isAfter: boolean;
};
const getValidationMessage = (val = {}): DateValidationT => ({
isDisabled: false,
isWeekend: false,
isEmpty: false,
isInvalid: false,
isBefore: false,
isAfter: false,
isValidDate: true,
...val,
});
export const useDatepicker = (
opt: UseDatepickerOptions = {}
): UseDatepickerValue => {
const {
locale: _locale = "nb",
required,
defaultSelected: _defaultSelected,
today = new Date(),
fromDate,
toDate,
disabled,
disableWeekends,
onDateChange,
inputFormat,
onValidate,
defaultMonth,
allowTwoDigitYear = true,
} = opt;
const locale = getLocaleFromString(_locale);
const inputRef = useRef<HTMLInputElement>(null);
const daypickerRef = useRef<HTMLDivElement>(null);
const [defaultSelected, setDefaultSelected] = useState(_defaultSelected);
// Initialize states
const [month, setMonth] = useState(defaultSelected ?? defaultMonth ?? today);
const [selectedDay, setSelectedDay] = useState(defaultSelected);
const [open, setOpen] = useState(false);
const defaultInputValue = defaultSelected
? formatDateForInput(defaultSelected, locale, "date", inputFormat)
: "";
const [inputValue, setInputValue] = useState(defaultInputValue);
const updateDate = (date?: Date) => {
onDateChange?.(date);
setSelectedDay(date);
};
const updateValidation = (val: Partial<DateValidationT> = {}) => {
const msg = getValidationMessage(val);
onValidate?.(msg);
};
const handleFocusIn = useCallback(
(e) => {
/* Workaround for shadow-dom users (open) */
const composed = e.composedPath?.()?.[0];
if (!e?.target || !e?.target?.nodeType || !composed) {
return;
}
![
daypickerRef.current,
inputRef.current,
inputRef.current?.nextSibling,
].some(
(element) => element?.contains(e.target) || element?.contains(composed)
) &&
open &&
setOpen(false);
},
[open]
);
useEffect(() => {
window.addEventListener("focusin", handleFocusIn);
window.addEventListener("pointerdown", handleFocusIn);
return () => {
window?.removeEventListener?.("focusin", handleFocusIn);
window?.removeEventListener?.("pointerdown", handleFocusIn);
};
}, [handleFocusIn]);
const reset = () => {
updateDate(defaultSelected);
setMonth(defaultSelected ?? defaultMonth ?? today);
setInputValue(defaultInputValue ?? "");
setDefaultSelected(_defaultSelected);
};
const setSelected = (date: Date | undefined) => {
updateDate(date);
setMonth(date ?? defaultMonth ?? today);
setInputValue(
date ? formatDateForInput(date, locale, "date", inputFormat) : ""
);
};
const handleFocus: React.FocusEventHandler<HTMLInputElement> = (e) => {
!open && setOpen(true);
let day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear
);
if (isValidDate(day)) {
setMonth(day);
setInputValue(formatDateForInput(day, locale, "date", inputFormat));
}
};
const handleBlur: React.FocusEventHandler<HTMLInputElement> = (e) => {
let day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear
);
isValidDate(day) &&
setInputValue(formatDateForInput(day, locale, "date", inputFormat));
};
/* Only allow de-selecting if not required */
const handleDayClick: DayClickEventHandler = (day, { selected }) => {
if (day && !selected) {
setOpen(false);
inputRef.current && inputRef.current.focus();
}
if (!required && selected) {
updateDate(undefined);
setInputValue("");
updateValidation({ isValidDate: false, isEmpty: true });
return;
}
updateDate(day);
updateValidation();
setMonth(day);
setInputValue(
day ? formatDateForInput(day, locale, "date", inputFormat) : ""
);
};
// When changing the input field, save its value in state and check if the
// string is a valid date. If it is a valid day, set it as selected and update
// the calendar’s month.
const handleChange: React.ChangeEventHandler<HTMLInputElement> = (e) => {
setInputValue(e.target.value);
const day = parseDate(
e.target.value,
today,
locale,
"date",
allowTwoDigitYear
);
const isBefore =
fromDate && day && differenceInCalendarDays(fromDate, day) > 0;
const isAfter = toDate && day && differenceInCalendarDays(day, toDate) > 0;
if (
!isValidDate(day) ||
(disableWeekends && isWeekend(day)) ||
(disabled && isMatch(day, disabled))
) {
updateDate(undefined);
updateValidation({
isInvalid: isValidDate(day),
isWeekend: disableWeekends && isWeekend(day),
isDisabled: disabled && isMatch(day, disabled),
isValidDate: false,
isEmpty: !e.target.value,
isBefore: isBefore ?? false,
isAfter: isAfter ?? false,
});
return;
}
if (isBefore || isAfter) {
updateDate(undefined);
updateValidation({
isValidDate: false,
isBefore: isBefore ?? false,
isAfter: isAfter ?? false,
});
return;
}
updateDate(day);
updateValidation();
setMonth(defaultMonth ?? day);
};
const handleClose = useCallback(() => {
setOpen(false);
inputRef.current && inputRef.current.focus();
}, []);
const escape = useCallback(
(e) => open && e.key === "Escape" && handleClose(),
[handleClose, open]
);
useEffect(() => {
window.addEventListener("keydown", escape, false);
return () => {
window.removeEventListener("keydown", escape, false);
};
}, [escape]);
const datepickerProps = {
month,
onMonthChange: (month) => setMonth(month),
onDayClick: handleDayClick,
selected: selectedDay ?? new Date("Invalid date"),
locale: _locale,
fromDate,
toDate,
today,
open,
onOpenToggle: () => setOpen((x) => !x),
disabled,
disableWeekends,
ref: daypickerRef,
};
const inputProps = {
onChange: handleChange,
onFocus: handleFocus,
onBlur: handleBlur,
value: inputValue,
ref: inputRef,
};
return { datepickerProps, inputProps, reset, selectedDay, setSelected };
};
Since UNSAFE_DatePicker
sets state with a prop and therefor the defaultSelected
is not updated, I thought of using the exposed function reset
or setSelected
to update UNSAFE_DatePicker
with the new defaultSelected
value.
What I find strange is that if I have a state flag in my parent component that checks if value has been updated:
const [valuesInitialized, setValuesInitialized] = useState(false);
const initialValues = useMemo(
() => ({
startDate: case?.startDate ? new Date(case.startDate) : undefined,
}),
[case]
);
const {
handleSubmit,
control,
reset,
formState: { errors },
} = useForm({
defaultValues: initialValues,
});
useEffect(() => {
if (case) {
reset(initialValues);
setValuesInitialized(true);
}
}, [case]);
Then I render DatePickerInput
like this:
<Controller
control={control}
name="startDate"
render={({ field }) => (
<DatePickerInput
label="Start date"
defaultSelected={field.value}
onChange={field.onChange}
valuesInitialized={valuesInitialized}
setValueInitialized={setValuesInitialized}
/>
)}
/>
And in DatePickerInput
if I set as a dependency valuesInitialized
to a useEffect
hook then everything works:
const { datepickerProps, inputProps, setSelected } = UNSAFE_useDatepicker({
onDateChange: (date) => {
onChange(date);
},
fromDate,
defaultSelected,
});
useEffect(() => {
setSelected(defaultSelected);
setValueInitialized(false);
}, [valuesInitialized]);
But, the problem is that I don't want to setValueInitialized(false)
from DatePickerInput
, since I might have several components and then they would be setting flag to false before other components are updated. So, I wanted to just have a value that is specific to this component as a dependency without the need to updated state of the parent component:
<Controller
control={control}
name="startDate"
render={({ field }) => (
<DatePickerInput
label="Start date"
defaultSelected={field.value}
onChange={field.onChange}
valuesInitialized={valuesInitialized}
setValueInitialized={setValuesInitialized}
initialValue={case?.startDate}
/>
)}
/>
And then check only initialValue if it has changed to update defaultSelected
of UNSAFE_DatePicker
:
useEffect(() => {
reset();
}, [initialValue]);
But, for some reason if I check initialValue
defaultSelected
is not updated. Why is this different than the solution with a flag?
How can I achieve this with just a value as a dependency?