0

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?

Leff
  • 1,968
  • 24
  • 97
  • 201
  • 1
    We'll need to see the code for `UNSAFE_useDatepicker` to tell you for sure, but the "default" in `defaultSelected` implies "this value only gets used on the first render". So the hook has probably been written so that it deliberately ignores a change to the value. – Nicholas Tower Feb 25 '23 at 14:03
  • I have updated the question with the ```UNSAFE_useDatepicker``` hook code @NicholasTower – Leff Feb 25 '23 at 14:17
  • `useState(_defaultSelected);` Yep, as i suspected. The value is used to initialize a state, nothing more. `useState` only looks at the default value on the first render, not later renders. – Nicholas Tower Feb 25 '23 at 14:24
  • Thanks for the answer! Isn't that an anti pattern, to set state with props? Also I thought ```useState``` would be called on every render, how come is that only called on first render? @NicholasTower – Leff Feb 25 '23 at 14:29
  • why `UNSAFE_useDatepicker` not started with `use`? – Abbas Bagheri Feb 25 '23 at 14:29
  • `Isn't that an anti pattern, to set state with props?` Setting **initial** state from props is quite common, and not an anti pattern. The thing that you should avoid is trying to constantly keep a prop and state in sync with eachother. If that happens, you should probably ditch the state and just use the prop. `Also I thought useState would be called on every render, how come is that only called on first render?` It is called on every render, that's how it keeps returning the state on every render. But it only pays attention to the initial value on the first render. – Nicholas Tower Feb 25 '23 at 15:49

0 Answers0