gpbl / react-day-picker

DayPicker is a customizable date picker component for React. Add date pickers, calendars, and date inputs to your web applications.
https://daypicker.dev
MIT License
5.86k stars 700 forks source link

Issue with State Management and Component Re-renders When Using Custom Components in DayPicker #2236

Open paolomatti01 opened 2 days ago

paolomatti01 commented 2 days ago

Description

I am encountering an issue with the DayPicker component when trying to manage external state in a custom component. Specifically, the Toggle component behaves inconsistently when included as part of a custom Caption in DayPicker. The state changes are not handled correctly, and the onCheckedChange event is called multiple times incorrectly.

Steps to Reproduce

  1. Set up a DayPicker component with a custom Caption that includes a Toggle component.
  2. Manage the toggle state outside of the DayPicker component.

Expected Behavior

The Toggle component should change state correctly and call the onCheckedChange handler only once per toggle action.

Actual Behavior

The Toggle component calls the onCheckedChange handler multiple times per action, leading to inconsistent state changes.

Minimal Reproducible Example

import React, { useState } from 'react';
import { DayPicker } from 'react-day-picker';
import { Toggle } from '../toggle';
import { Icon } from '@ui/components/ui/icon';

const CustomCaption = ({ onCheckedChange, checked }) => {
    return (
        <div>
            <Toggle checked={checked} onCheckedChange={onCheckedChange} />
        </div>
    );
};

const Calendar = () => {
    const [toggled, setToggled] = useState(false);

    const handleToggleChange = (newState) => {
        console.log('Toggle state changed to:', newState);
        setToggled(newState);
    };

    return (
        <DayPicker
            components={{
                Caption: () => <CustomCaption checked={toggled} onCheckedChange={handleToggleChange} />,
                IconLeft: () => <Icon type="arrow-left-2" />,
                IconRight: () => <Icon type="arrow-right-3" />,
            }}
        />
    );
};

export { Calendar };

Additional Context

When using the Toggle component standalone outside the DayPicker, it works as expected. However, integrating it within the DayPicker causes unexpected behavior. I suspect this might be due to how DayPicker manages its state and re-renders custom components.

Using a React.Context fixes the issue, but I find it really ugly:

import { DayPicker } from 'react-day-picker';
import {
    FC,
    createContext,
    useContext,
    useState,
    type ComponentProps,
    type PropsWithChildren,
} from 'react';
import { Toggle } from '../toggle';

export type CalendarProps = ComponentProps<typeof DayPicker>;

const ToggleContext = createContext<{
    toggled: boolean;
    setToggled: (val: boolean) => void;
}>({
    toggled: false,
    setToggled: () => null,
});

export const useToggle = () => useContext(ToggleContext);

export const ToggleProvider: FC<PropsWithChildren> = ({ children }) => {
    const [toggled, setToggled] = useState(false);

    return (
        <ToggleContext.Provider value={{ toggled, setToggled }}>
            {children}
        </ToggleContext.Provider>
    );
};

const Caption = () => {
    const { toggled, setToggled } = useToggle();

    return (
        <div>
            <Toggle checked={toggled} onCheckedChange={setToggled} />
        </div>
    );
};

const Calendar: FC<CalendarProps> = originalProps => {
    const {
        className,
        classNames,
        showOutsideDays = true,
        ...props
    } = originalProps;

    return (
        <ToggleProvider>
            <DayPicker
                showOutsideDays={showOutsideDays}
                className={className}
                classNames={{
                    months: 'flex flex-col sm:flex-row space-y-4 sm:space-x-4 sm:space-y-0',
                    month: 'space-y-4',
                    caption: 'flex justify-center py-4 relative items-center',
                    caption_label: 'text-detail-lg text-text-static-loud',
                    nav: 'space-x-1 flex items-center',
                    nav_button:
                        'absolute text-[1.25rem] text-shape-static-moderate hover:text-shape-static-loud',
                    nav_button_previous: 'left-[3rem]',
                    nav_button_next: 'right-[3rem]',
                    table: 'w-full border-collapse space-y-1',
                    head_row: 'flex',
                    head_cell:
                        'w-12 h-12 flex items-center justify-center bg-container-selection-mellow-default text-detail-md text-text-static-moderate',
                    row: 'flex w-full',
                    cell: 'h-12 w-12 text-center p-0 relative [&:has([aria-selected].day-range-end)]:rounded-r-md [&:has([aria-selected].day-outside)]:bg-accent/50 [&:has([aria-selected])]:bg-accent first:[&:has([aria-selected])]:rounded-l-md last:[&:has([aria-selected])]:rounded-r-md focus-within:relative focus-within:z-20',
                    day: `
                        flex items-center justify-center h-12 w-12 rounded-full bg-container-selection-date-default text-detail-md text-text-selection-date-default
                        hover:bg-container-selection-date-hover hover:border hover:border-border-selection-date-hover hover:text-text-selection-date-hover
                        focus-visible:bg-container-selection-date-focused focus-visible:outline-none focus-visible:border-2 focus-visible:border-border-selection-date-foocused focus-visible:text-text-selection-date-focused
                        disabled:bg-container-selection-date-disabled disabled:text-text-selection-date-disabled
                        aria-selected:bg-container-selection-date-active aria-selected:text-text-selection-date-active
                    `,
                    day_selected:
                        'bg-container-selection-date-active text-text-selection-date-active',
                    day_today: 'bg-container-selection-date-today',
                    day_outside:
                        'day-outside bg-container-selection-date-disabled text-text-selection-date-disabled',
                    day_disabled:
                        'bg-container-selection-date-disabled text-text-selection-date-disabled',
                    day_hidden: 'invisible',
                    ...classNames,
                }}
                components={{
                    Caption: () => <Caption />,
                }}
                {...props}
            />
        </ToggleProvider>
    );
};

Calendar.displayName = 'Calendar';

export { Calendar };

Environment

Screenshots / Recordings

https://github.com/gpbl/react-day-picker/assets/37446572/1c090305-75e6-412a-ab52-dd3fa9e3a01f

Links