Haydenull

Haydenull

A front-end developer with a passion for using technology to increase personal efficiency and productivity 💡.
twitter

How to implement a time picker that conforms to React design

Recently, while summarizing React Flight Rules, I encountered a time picker in a business requirement, which is quite suitable for a case study.

Implementing a Basic Version of the Time Picker#

The UI of the picker is as follows:

image

This picker is divided into two parts:

  1. The first part is a default set of time buttons, which directly set the picker to the corresponding value when clicked.
  2. The second part is an input box where users can enter a custom time.

It seems to have a simple function, so let's implement a basic version of the picker:

import { type Dayjs } from 'dayjs'

const FORMAT = 'HH:mm'
const defaultOptions = [
  dayjs('08:00', FORMAT),
  dayjs('10:00', FORMAT),
  dayjs('16:00', FORMAT),
]

const MyTimeRadio: React.FC<{
  value: Dayjs;
  onChange: (value: Dayjs) => void;
}> = ({ value, onChange }) => {
  const [timeInputValue, timeInputValue] = useState<Dayjs>()
  const isCustomTime = !defaultOptions.includes(value)

  return (
    <div>
      {defaultOptions.map((option) => (
        <button
          key={option.format(FORMAT)}
          onClick={() => onChange(option)}
        >
          {option.format(FORMAT)}
        </button>
      ))}

      <div>
        <span>Custom Time</span>
        <TimeInput
          value={timePickerValue}
          onChange={(time) => {
            setTimePickerValue(time)
            onChange(time)
          }}
        />
      </div>
    </div>
  )
}

Here we added a state called timeInputValue to save the user's input time. Why not use value directly? Because value is a prop shared by the entire component. When the user clicks the button for 10 o'clock, value will change to 10 o'clock. If the TimeInput component uses value directly, it will cause the input box's value to also change to 10 o'clock, which is clearly not the effect we want.

image

So we used a timeInputValue to save the user's input value. When the user clicks a button, timeInputValue will not change, ensuring that the input box's value will not be overwritten.

It seems quite functional and simple, but this component has an echo issue:

Suppose we place the component in a form, and the initial data for the form needs to be fetched from an API. The code is as follows:

const MyForm: React.FC = () => {
  const [time, setTime] = useState<Dayjs>()

  useEffect(() => {
    fetch('/api/time').then((res) => {
      // Suppose res.time is 12:00
      setTime(res.time)
    })
  }, [])

  return (
    <form>
      <MyTimeRadio value={time} onChange={setTime} />
    </form>
  )
}

We find that when MyTimeRadio is rendered for the first time, the obtained value is undefined. After fetching the time returned from the API, value will change to 12:00. At this point, MyTimeRadio will re-render, but timeInputValue is still undefined.

image

Thus, our component cannot properly echo the custom time and needs a small modification.

Complete Functionality Version One#

After discovering this issue, my first reaction was that we want value to correctly sync to timeInputValue, so why not just add a listener?

// MyTimeRadio.tsx
const [timeInputValue, timeInputValue] = useState<Dayjs>()

useEffect(() => {
  setTimeInputValue(value)
}, [value])

// ...
<TimeInput
  value={timeInputValue}
/>

Hmm, it seems feasible, but we cannot let timeInputValue always follow value, because when the user clicks the 10 o'clock button triggering onChange, which ultimately causes value to change, we do not want timeInputValue to be overwritten. So we need to filter it out again:

// MyTimeRadio.tsx
const [timeInputValue, timeInputValue] = useState<Dayjs>()
useEffect(() => {
  if (isCustomTime) setTimeInputValue(value)
}, [value, isCustomTime])

// ...
<TimeInput
  value={timeInputValue}
/>

Perfect, we listened to the changes in value, synced the value to timeInputValue, and added a check for isCustomTime to prevent the user's input value from being overwritten.

Testing the component, the echo bug is resolved.

But wait, we clearly stated in React Flight Rules that the purpose of useEffect is not to provide a callback hook after rendering is complete, nor is it to implement lifecycle methods in functional components. From its name, we can tell that it exists to handle side effects. The correct approach is to ==use useEffect to synchronize React applications with external states==.

In our useEffect code, both value and isCustomTime are internal states of React, so using useEffect here does not align with its design intent.

So how can we solve this problem without using useEffect?

Complete Functionality Version Two#

If we use useRef to replace the timeInputValue state, we can modify its value anywhere, thus eliminating the need for useEffect.

// MyTimeRadio.tsx
const timeInputValueBackRef = useRef<Dayjs>()
if (isCustomTime) timeInputValueBackRef.current = value

const timeInputValue = isCustomTime ? value : timeInputValueBackRef.current

// ...
<TimeInput
  value={timeInputValue}
/>

After making the changes, the functionality works normally, but another issue arises. Because our flight rules also state that ==we should ensure that React's rendering code is a pure function==, when we include reading and modifying the ref in the rendering logic, it is no longer a pure function.

Why do I say this? Because a ref is a value that is shared during every component render, akin to an external variable, and our rendering logic is a pure function that should not depend on (read) external variables, let alone modify them.

Let's take a simple example:

const add = (a: number) => ++a

add(1) // 2
add(1) // 2
add(1) // 2

No matter how many times the code executes, the return value of add(1) is fixed, which is the characteristic of a pure function.

But if we change the add function like this:

let a = 1
const add = () => ++a

add(1) // 2
add(1) // 3
add(1) // 4

Since a is an external variable, the return value of add(1) is no longer fixed, which means it is not a pure function.

Returning to the problem, how can we eliminate the ref during the rendering phase?

Complete Functionality Version Three#

Looking back at the code above, to avoid using useEffect, we placed the value of timeInputValue in a ref, and then we utilized the rerendering feature caused by changes in props.value to assign the value of value to timeInputValue.

However, our original intention should be to solve the problem of the TimeInput component's value being overwritten by value, so returning to this point, our ref should be used to save a copy of the TimeInput component's value, and it would be more reasonable to assign the value in the onChange of TimeInput.

// MyTimeRadio.tsx
const timeInputValueBackRef = useRef<Dayjs>()
const isCustomTime = !defaultOptions.includes(value)

const timeInputValue = isCustomTime ? value : timeInputValueBackRef.current

// ...
<TimeInput
  value={timeInputValue}
  onChange={(time) => {
    timeInputValueBackRef.current = time
    onChange(time)
  }}
/>

Now, our ref is only modified in the onChange of TimeInput, meaning that the modification of the external variable ref has been moved outside the rendering logic, which increases the purity of rendering.

However, rendering still reads the ref, making it a non-pure function.

At this point, aside from useEffect, I couldn't think of any way to move both the reading and modifying of the ref outside of rendering. Until today, I revisited the documentation for react useState and useRef, and I had a new idea.

Complete Functionality Version Four#

const [timePickerValue, setTimePickerValue] = useState<Dayjs>()

if (isCustomTime && timePickerValue !== value) setTimePickerValue(value)

// ...
<TimeInput
  value={timeInputValue}
/>

We only need to add one line of code to our initial version: if (isCustomTime && timePickerValue !== value) setTimePickerValue(value).

Why did I abandon useRef and switch back to useState?

Because in the documentation for useRef, React clearly states ==do not read or modify the value of the ref during rendering==; if you need to read or modify the value during rendering, use state instead.

My previous understanding of ref was influenced by class component instance properties, treating it as a variable shared across multiple renders, which led me to mistakenly place it in the rendering logic, resulting in versions two and three.

The documentation recommends three scenarios where ref should be suitable:

  • Storing timeout IDs
  • Storing DOM nodes
  • Storing mutable values that do not need to participate in JSX calculations, meaning they will not affect the generated JSX.

Reading more documentation is beneficial; during coding, we need to think critically and not blindly apply existing knowledge. The mental model of functional components is different from that of class components, and we need to thoroughly understand their differences to write better code.

Returning to the fourth version of the code, why didn't I think of this solution initially? Because I instinctively rejected using setState during rendering, as it could lead to an infinite loop (in this case, we avoided this situation using if).

React documentation also mentions this, such behavior should be avoided, and in most cases, state should be modified in event callbacks, with only a few situations requiring state modification to adapt to rendering (which is the case we encountered).

So what are the benefits of this approach compared to the first version using useEffect?

  • More aligned with React's mental model
  • Better performance

When we use useEffect, the component looks like this:

image

Since the timing of useEffect is after the render is complete, the component undergoes two complete renders, including its child components.

However, when using setState, the component looks like this:

image

In render 1, I used a gray background because when the component reaches the return JSX, it immediately triggers a rerender, skipping the execution of its child components.

Summary#

In this article, we attempted to discuss the usage scenarios of the three hooks: useState, useRef, and useEffect, and tried several solutions to solve a practical problem.

To summarize some important points:

  • The name of useEffect is effect, and its purpose is to handle side effects, synchronizing React with the external world.
  • Rendering should be ensured to be a pure function.
  • The value of a ref should not be read or modified in rendering logic; state should be used instead.
  • Existing knowledge should not be blindly applied; understanding the mental model of React functional components is crucial. Thinking in React.
Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.