Search code examples
reactjstypescriptreact-hooksreact-datepicker

How I do keep from updating the state before the end date is chosen in a date range with react-datepicker?


I have a bit of a complicated application where I'm making a chart based on the user input values. They pick a start date, and end date, and another parameter. When that extra parameter is filled, it renders the chart. The problem is when the user needs to edit the dates, in react-datepicker, the start date and end date chosen are updated individually, so it updates the start date and resets the end date to null before the user has chosen an end date, causing the app to error out. I need to figure out how to rework the state updating, so the user has the opportunity to pick an end date when editing the dates before the chart re-renders.

Parent component:

export const OutagePanel: FC<propTypes> = (props) => {
  const [parameters, setParameters] = useState({
    startDate: null,
    endDate: null,
    unit: 'None'
  })

  const handleDateChange = (range) => {
    const [startDate, endDate] = range;
    setParameters( (prevState) => ({
      ...prevState,
      startDate: startDate,
      endDate: endDate
    }))
  }

  const handleUnitChange = (value) => {
    setParameters( (prevState) => ({
      ...prevState,
      unit: value
    }))
  }

  return (
    <Flex>
      <Flex>
        <Box>Date Range:</Box>
        <Box>
          <DatePicker
            className={styles.datepicker}
            placeholderText="Click to select"
            selected={parameters.startDate}
            startDate={parameters.startDate}
            endDate={parameters.endDate} 
            onChange={handleDateChange}
            showMonthDropdown
            showYearDropdown
            dropdownMode="select"
            minDate={new Date(2000, 0, 1)}
            maxDate={new Date(2021, 0, 5)}
            todayButton="Today"
            selectsRange
          />
        </Box>
      </Flex>
      <GeneratorUnitSelect handleUnitChange={handleUnitChange} />
      {parameters.unit != 'None' && <OutageHistoryChart parameters={parameters}></OutageHistoryChart>}
    </Flex>
  )
}

As seen above, when parameters.unit != 'None' it will show the OutageHistoryChart component. So after it first successfully creates and displays a chart, when a user goes back to edit the dates, upon first click in the date picker, it will update the state to something like this:

parameters = {
  startDate: <user's new date>,
  endDate: null,
  unit: 'Unit 1'
}

Since the updated state still contains a valid value in parameters.unit it passes my test in the return statement and tries to re-render the chart. I know I could add an additional test parameters.endDate != null before showing the chart and that is likely to fix it, however, it seems like I should be able to use a useEffect here. This is what I have tried, but the useEffect gets skipped upon editing the date range and it again fails to render the chart due to the end date missing.

export const OutagePanel: FC<propTypes> = (props) => {
  const [parameters, setParameters] = useState({
    startDate: null,
    endDate: null,
    unit: 'None'
  })

  const [showChart, setShowChart] = useState(false)
    
  const handleDateChange = (range) => {
    const [startDate, endDate] = range;
    setParameters( (prevState) => ({
      ...prevState,
      startDate: startDate,
      endDate: endDate
    }))
  }

  useEffect(() => {
    if (parameters.unit != 'None' && parameters.endDate != null) {
      setShowChart(true)
    } else (
      setShowChart(false)
    )
  }, [parameters.startDate, parameters.endDate, parameters.unit])

  //more stuff

Then I changed it to this in the return statement:

{showChart && <OutageHistoryChart parameters={parameters}></OutageHistoryChart>}

Is this a case where a useEffect is not a valid solution or am I just implementing it wrong? It just seems messy to do checking like parameter.unit != 'None' && parameter.endDate != null in my return statement.


Solution

  • The issue with using a useEffect hook to set a showCart state is that this leaves open at least one render cycle where showCart is possibly still true from a previous render and the parameters.unit isn't equal to "None" and the parameters.endDate has been updated to null. To guard this you're still sort of left checking parameter !== "None" and/or parameter.endDate !== null to conditionally render the OutageHistoryChart.

    Because of this delay to update the showCart state you're better off computing a showCart value. It's considered a React anti-pattern to store derived "state" in React state anyway.

    Example:

    const showCart = parameters.unit !== "None" && !!parameters.endDate;
    

    If computing the derived state was "expensive" then you should use the useMemo hook to compute and memoize the result value. In fact... just about any time you find that you've coded a useState|useEffect combo to hold/update some "derived" state, you should probably be using the useMemo hook.

    const showCart = useMemo(() => {
      return parameters.unit !== "None" && !!parameters.endDate;
    }, [parameters.unit, parameters.endDate]);
    

    Complete code example:

    export const OutagePanel: FC<propTypes> = (props) => {
      const [parameters, setParameters] = useState({
        startDate: null,
        endDate: null,
        unit: 'None'
      });
    
      const handleDateChange = (range) => {
        const [startDate, endDate] = range;
        setParameters( (prevState) => ({
          ...prevState,
          startDate,
          endDate
        }));
      };
    
      const handleUnitChange = (value) => {
        setParameters( (prevState) => ({
          ...prevState,
          unit: value
        }));
      };
    
      const showCart = parameters.unit !== "None" && !!parameters.endDate;
    
      return (
        <Flex>
          <Flex>
            <Box>Date Range:</Box>
            <Box>
              <DatePicker
                className={styles.datepicker}
                placeholderText="Click to select"
                selected={parameters.startDate}
                startDate={parameters.startDate}
                endDate={parameters.endDate} 
                onChange={handleDateChange}
                showMonthDropdown
                showYearDropdown
                dropdownMode="select"
                minDate={new Date(2000, 0, 1)}
                maxDate={new Date(2021, 0, 5)}
                todayButton="Today"
                selectsRange
              />
            </Box>
          </Flex>
          <GeneratorUnitSelect handleUnitChange={handleUnitChange} />
          {showCart && <OutageHistoryChart parameters={parameters} />}
        </Flex>
      );
    };
    

    Given the above, I think a slightly simpler solution would be to "reset" the parameters.unit state back to "None" if/when the endDate is going to be updated to null, or falsey, value.

    const handleDateChange = (range) => {
      const [startDate, endDate] = range;
      setParameters( (prevState) => ({
        ...prevState,
        startDate,
        endDate,
        ...!!endDate ? {} : { unit: "None" },
      }));
    };
    

    This allows the current condition to still work as expected.

    Of course, the parameters.unit state should probably now also be passed to GeneratorUnitSelect so it's a controlled input and will "react" and show the current unit value (there will need to be a small update to that component to accommodate).

    <GeneratorUnitSelect
      handleUnitChange={handleUnitChange}
      value={parameters.unit}
    />
    {parameters.unit !== 'None' && <OutageHistoryChart parameters={parameters} />}