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:
This picker is divided into two parts:
- The first part is a default set of time buttons, which directly set the picker to the corresponding value when clicked.
- 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.
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.
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:
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:
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.