15

I'm creating a DatePicker and using useState hook to manage it's visibility. On div click I've added the event listener which changes value, but it didn't work as I expected. It works only the first time, so initial value changes to true, but on second and third clicks this value stays to true and DatePicker stays visible on click.

This is DatePicker

import React, { useState } from 'react';
import OutsideClickHandler from 'react-outside-click-handler';
import { renderInfo, getWeeksForMonth } from './utils';
import {
    renderMonthAndYear,
    handleBack,
    handleNext,
    weekdays,
} from '../../utils';

const DatePicker = ({
    isOpen,
    setIsOpen,
    selected,
    setSelected,
    dayClick,
    dayClass,
}) => {
    const startDay = new Date().setHours(0, 0, 0, 0);

    const [current, setCurrent] = useState(new Date(startDay));

    const weeks = getWeeksForMonth(current.getMonth(), current.getFullYear());

    function handleClick(date) {
        if (date > startDay) {
            setSelected(date);
            setCurrent(date);
            setIsOpen(false);

            if (dayClick) {
                dayClick(date);
            }
        }
    }

    return (
        <div className="DatePicker-container">
            <div
                tabIndex="0"
                role="button"
                className="DatePicker-info"
                onKeyPress={e => {
                    if (e.which === 13) {
                        setIsOpen(!isOpen);
                    }
                }}
                onClick={e => {
                    setIsOpen(!isOpen);
                }}
            >
                {renderInfo(selected)}
            </div>
            {isOpen && (
                <OutsideClickHandler onOutsideClick={() => setIsOpen(false)}>
                    <div className="DatePicker">
                        <div className="DatePicker__header">
                            <span
                                role="button"
                                onClick={() => handleBack(current, setCurrent)}
                                className="triangle triangle--left"
                            />

                            <span className="DatePicker__title">
                                {renderMonthAndYear(current)}
                            </span>

                            <span
                                role="button"
                                onClick={() => handleNext(current, setCurrent)}
                                className="triangle triangle--right"
                            />
                        </div>

                        <div className="DatePicker__weekdays">
                            {weekdays.map(weekday => (
                                <div
                                    key={weekday}
                                    className="DatePicker__weekday"
                                >
                                    {weekday}
                                </div>
                            ))}
                        </div>
                        {weeks.map((week, index) => (
                            <div
                                role="row"
                                key={index}
                                className="DatePicker__week"
                            >
                                {week.map((date, index) =>
                                    date ? (
                                        <div
                                            role="cell"
                                            key={index}
                                            onClick={() => handleClick(date)}
                                            className={dayClass(date)}
                                        >
                                            {date.getDate()}
                                        </div>
                                    ) : (
                                        <div
                                            key={index}
                                            className="DatePicker__day--empty"
                                        />
                                    ),
                                )}
                            </div>
                        ))}
                    </div>
                </OutsideClickHandler>
            )}
        </div>
    );
};

export default DatePicker;

DateRangePicker which uses two.

import React, { useState } from 'react';
import DatePicker from './DatePicker';
import DatePickerContext from './DatePickerContext';
import './DatePicker.scss';

const DateRangePicker = () => {
    const startDay = new Date().setHours(0, 0, 0, 0);

    const [isOpen, setIsOpen] = useState(false);
    const [isSecondOpen, setIsSecondOpen] = useState(false);
    const [selected, setSelected] = useState(new Date(startDay));
    const [secondSelected, setSecondSelected] = useState(new Date(startDay));

    function dayClass(date) {
        if (
            selected.getTime() === date.getTime() ||
            (date >= selected && date <= secondSelected)
        ) {
            return 'DatePicker__day DatePicker__day--selected';
        }
        if (date < startDay || date < selected) {
            return 'DatePicker__day DatePicker__day--disabled';
        }
        return 'DatePicker__day';
    }

    function dayClick(date) {
        setSecondSelected(date);
        setIsSecondOpen(true);
    }

    return (
        <DatePickerContext.Provider>
            <div className="DatePicker-wrapper">
                <DatePicker
                    key={1}
                    isOpen={isOpen}
                    setIsOpen={setIsOpen}
                    selected={selected}
                    setSelected={setSelected}
                    dayClick={dayClick}
                    dayClass={dayClass}
                />
                <DatePicker
                    key={2}
                    isOpen={isSecondOpen}
                    setIsOpen={setIsSecondOpen}
                    selected={secondSelected}
                    setSelected={setSecondSelected}
                    dayClass={dayClass}
                />
            </div>
        </DatePickerContext.Provider>
    );
};

export default DateRangePicker;
Prateek Gupta
  • 119
  • 2
  • 7
Mirian Okradze
  • 335
  • 1
  • 5
  • 9

1 Answers1

28

a) It is a good practice to use functional updates to make sure to use correct "current" value when the next state is dependent on the previous (== current) state:

setIsOpen(currentIsOpen => !currentIsOpen)

b) It's very hard to reason about the next state when it gets updated by multiple handlers executed for the same event. Following 2 handlers might execute on the same click (the 1st div is "outside"):

<div ... onClick={e => setIsOpen(!isOpen)}>
<OutsideClickHandler onOutsideClick={() => setIsOpen(false)}>

If onOutsideClick executes first, then React re-renders with isOpen=false, and then onClick executes second, it would set isOpen=true as you observe - I don't see how the re-render could happen between, but maybe OutsideClickHandler is doing something nefarious or your code is more complicated than in the question ¯\_(ツ)_/¯

To enforce only 1 event handler:

<OutsideClickHandler onOutsideClick={(e) => {
  e.stopPropagation();
  setIsOpen(false);
}}>
Aprillion
  • 21,510
  • 5
  • 55
  • 89
  • 2
    This is of course correct, but it doesn't explain why OP's code doesn't work. I created a similar [snippet](https://codesandbox.io/embed/confident-morse-h0939) and it seems to work for me just fine. Any ideas? – Tobias Tengler Jul 27 '19 at 11:45
  • 1
    @TobiasTengler the question was updated since my answer, at which time it looked as the only possible source of the problem. I have updated my answer to reflect the new information. – Aprillion Jul 27 '19 at 13:51
  • Oh, I got it. So when I click on div it's outside and it changes state and also second click handler changes which sets to same value. Thanks – Mirian Okradze Jul 27 '19 at 20:15
  • For posterity, capturing is enabled by default in https://github.com/airbnb/react-outside-click-handler/blob/master/src/OutsideClickHandler.jsx, which will cause the stopPropagation call to take no effect. – Elmo Nov 29 '19 at 20:29
  • @Elmo if I understand it correctly, `useCapture` should work around `stopPropagation` of component's children, but stopping propagation of `onOutsideClick` into the parent should still work.. not enough experience to be 100% sure though – Aprillion Jan 09 '20 at 15:15
  • @Aprillion in which cases not using functional updates could lead to unwanted behaviour? I mean if you have `const [st, setSt] = useState(false)` in which situations `st` could be different than the one passed to the function passed to `setSt`? – Zeno Dalla Valle Mar 17 '21 at 13:05
  • 1
    @ZenoDallaValle in any case of a so-called "stale closure", most often inside `useCallback` when ignoring the eslint rule for exhaustive deps, but see also https://stackoverflow.com/q/54069253/1176601 – Aprillion Mar 17 '21 at 13:23
  • 1
    but other times it can be useful to remember the closure and NOT rely on the newest value, see e.g. https://epicreact.dev/how-react-uses-closures-to-avoid-bugs/ – Aprillion Mar 17 '21 at 13:24