英文:
useImperativeHandle way vs useState way
问题
React建议首选第一种解决方案(使用状态而不是useImperativeHandle,只在必要时使用),因为它更符合React的设计哲学和组件封装原则。以下是一些关于为什么React可能更推荐第一种解决方案的原因:
-
可维护性和可理解性: 第一种方法使用了父组件传递状态和回调函数的方式,使得组件之间的数据流更加清晰和可理解。这有助于提高代码的可维护性,因为数据和逻辑更容易追踪和调试。
-
组件复用: 第一种方法更容易实现组件的重用。您可以将
CountDownTimer
组件在应用的其他部分使用,而不需要传递ref
或者特定的函数。 -
React的声明性: React推崇声明性编程,其中组件的行为由其props和状态决定。第一种方法更符合这个原则,因为它明确地传递了计时器的值和逻辑,而不是通过
ref
来操作组件的内部状态。
尽管第二种方法可以将组件内部的逻辑封装得更好,但它可能使代码复杂化,增加了不必要的复杂性。通常情况下,React建议只在必要时使用useImperativeHandle
,例如在与非React库(如D3或Three.js)集成时,或者在必须以编程方式控制子组件的情况下使用。在大多数情况下,使用props和状态管理组件的行为更加合适和清晰。
英文:
I have a CountDownTimer
component. This component should be able to get a current countDownTimerValue
from the parent component and start the timer that way. The parent component disables some components if the countdown has started. This can be implemented in two ways:
- using states and passing them from the parent component
- using the useImperativeHandle to pass some functions from the CountDownTimer component
The first implementation looks like this:
CountDownComponent:
import React, { useEffect, useRef } from "react";
export type Props = {
seconds: number;
decreaseCountDown: () => void;
};
export const isCountDownTimerVisible = (currentTimer: number) => {
return currentTimer > MIN_TIMER_VALUE;
}
export const MIN_TIMER_VALUE = -1;
const CountDownTimer = ({ seconds, decreaseCountDown }: Props) => {
const intervalId = useRef<NodeJS.Timer | undefined>(undefined);
useEffect(() => {
createIntervalIfRequired();
return () => clearIntervalIfRequired();
}, [seconds]);
const createIntervalIfRequired = () => {
if (intervalId.current !== undefined) {
return;
}
if (seconds <= MIN_TIMER_VALUE) {
return;
}
const createdIntervalId = setInterval(() => {
decreaseCountDown();
}, 1000);
intervalId.current = createdIntervalId;
};
const clearIntervalIfRequired = () => {
if (seconds > MIN_TIMER_VALUE || intervalId.current == undefined) {
return;
}
clearInterval(intervalId.current);
intervalId.current = undefined;
};
return (
<span className={`px-2 ${seconds <= MIN_TIMER_VALUE ? "hidden" : ""}`}>
{beautifyTime(seconds)}
</span>
);
};
const beautifyTime = (time: number): string => {
let minutes = parseInt((time / 60).toString()).toString();
let seconds = parseInt((time % 60).toString()).toString();
if (seconds.length == 1) {
seconds = "0" + seconds;
}
if (minutes.length == 1) {
minutes = "0" + minutes;
}
return `${minutes}:${seconds}`;
};
export default CountDownTimer;
By doing this the parent component should hold the current timer and the logic to decrease the value for this component which I think is something internal to this component and I don't like that this is something that is created within the parent component.
Parent component:
// Other code ...
{!isCountDownTimerVisible(countDownTimerValue) && t("Send pin2 Request")}
<CountDownTimer
seconds={countDownTimerValue}
decreaseCountDown={() => {
if (sendPin2Status == "error") {
setCountDownTimer(MIN_TIMER_VALUE);
return;
}
setCountDownTimer((currentCounter) => currentCounter - 1);
}}
/>
Now let's take a look at the imperative way.
CountDownComponent:
import React, {
forwardRef,
useEffect,
useImperativeHandle,
useRef,
useState
} from "react";
export type CountDownTimerApi = {
isVisible: () => boolean;
setTimer: (seconds: number) => void;
};
export type Props = {
ref: React.MutableRefObject<CountDownTimerApi>;
};
export const isCountDownTimerVisible = (currentTimer: number) => {
return currentTimer > MIN_TIMER_VALUE;
}
export const MIN_TIMER_VALUE = -1;
const CountDownTimer = forwardRef(function CountDownTimer({ ref }: Props) {
const intervalId = useRef<NodeJS.Timer | undefined>(undefined);
const [countDownTimer, setCountDownTimer] = useState<number>(-1);
useImperativeHandle(
ref,
() => {
return {
isVisible: () => {
return countDownTimer >= MIN_TIMER_VALUE;
},
setTimer: (seconds: number) => {
setCountDownTimer(seconds);
}
};
},
[countDownTimer]
);
useEffect(() => {
createIntervalIfRequired();
return () => clearIntervalIfRequired();
}, [countDownTimer]);
const createIntervalIfRequired = () => {
if (intervalId.current !== undefined) {
return;
}
if (countDownTimer <= MIN_TIMER_VALUE) {
return;
}
const createdIntervalId = setInterval(() => {
setCountDownTimer((previousValue) => previousValue - 1);
}, 1000);
intervalId.current = createdIntervalId;
};
const clearIntervalIfRequired = () => {
if (countDownTimer > MIN_TIMER_VALUE || intervalId.current === undefined) {
return;
}
clearInterval(intervalId.current);
intervalId.current = undefined;
};
return (
<span className={`px-2 ${countDownTimer <= MIN_TIMER_VALUE ? "hidden" : ""}`}>
{beautifyTime(countDownTimer)}
</span>
);
});
const beautifyTime = (time: number): string => {
let minutes = parseInt((time / 60).toString()).toString();
let seconds = parseInt((time % 60).toString()).toString();
if (seconds.length === 1) {
seconds = "0" + seconds;
}
if (minutes.length === 1) {
minutes = "0" + minutes;
}
return `${minutes}:${seconds}`;
};
export default CountDownTimer;
By doing this everything internal to the component is encapsulated within the component. This I like. But the react docs suggests to not use this hook and use states if possible.
Parent component:
// other code ...
{!countDownTimerRef.current?.isVisible() && t("Send pin2 Request")}
<CountDownTimer ref={countDownTimerRef}/>
Why does React suggest the first solution (using states instead of useImperativeHandle whenever possible, see Pitfalls section) whilst the second one encapsulates everything related to the component within the component?
答案1
得分: 1
The primary reason why React advises against useImperativeHandle
whenever possible is because it goes against one of the core philosophies of React: data flow should be explicit and easy to follow. useImperativeHandle
breaks this convention by allowing parent components to access and manipulate the state of its children directly, bypassing the normal unidirectional data flow.
This may not be a problem for small projects or if used sparingly, but for large projects with multiple developers, it could potentially lead to code that is difficult to understand and maintain. When data changes happen, it would be hard to trace where the changes come from. It could also lead to bugs that are difficult to track down, since any part of the code that has access to the ref can modify it.
On the other hand, when using state and props, the data flow is clear and unidirectional: data is passed down from parent to child through props and changes in child components are communicated to the parent through callbacks.
In your particular case, using useImperativeHandle
seems to lead to cleaner code since it keeps everything related to the countdown timer inside the CountDownTimer
component. This is a valid consideration, and there are definitely cases where useImperativeHandle
can be the better choice. However, even without useImperativeHandle
, you could still achieve a similar degree of encapsulation by creating a custom hook that handles all the timer-related logic:
function useCountdown(seconds) {
const [countDownTimer, setCountDownTimer] = useState(seconds);
const intervalId = useRef<NodeJS.Timer | undefined>(undefined);
useEffect(() => {
createIntervalIfRequired();
return () => clearIntervalIfRequired();
}, [countDownTimer]);
const createIntervalIfRequired = () => {
if (intervalId.current !== undefined) {
return;
}
if (countDownTimer <= MIN_TIMER_VALUE) {
return;
}
const createdIntervalId = setInterval(() => {
setCountDownTimer((previousValue) => previousValue - 1);
}, 1000);
intervalId.current = createdIntervalId;
};
const clearIntervalIfRequired = () => {
if (countDownTimer > MIN_TIMER_VALUE || intervalId.current === undefined) {
return;
}
clearInterval(intervalId.current);
intervalId.current = undefined;
};
return [countDownTimer, setCountDownTimer];
}
You can use this hook in your CountDownTimer
component as shown in the code you provided.
英文:
The primary reason why React advises against useImperativeHandle
whenever possible is because it goes against one of the core philosophies of React: data flow should be explicit and easy to follow. useImperativeHandle
breaks this convention by allowing parent components to access and manipulate the state of its children directly, bypassing the normal unidirectional data flow.
This may not be a problem for small projects or if used sparingly, but for large projects with multiple developers, it could potentially lead to code that is difficult to understand and maintain. When data changes happen, it would be hard to trace where the changes come from. It could also lead to bugs that are difficult to track down, since any part of the code that has access to the ref can modify it.
On the other hand, when using state and props, the data flow is clear and unidirectional: data is passed down from parent to child through props and changes in child components are communicated to the parent through callbacks.
In your particular case, using useImperativeHandle
seems to lead to cleaner code since it keeps everything related to the countdown timer inside the CountDownTimer
component. This is a valid consideration, and there are definitely cases where useImperativeHandle
can be the better choice.However, even without useImperativeHandle
, you could still achieve a similar degree of encapsulation by creating a custom hook that handles all the timer-related logic:
function useCountdown(seconds) {
const [countDownTimer, setCountDownTimer] = useState(seconds);
const intervalId = useRef<NodeJS.Timer | undefined>(undefined);
useEffect(() => {
createIntervalIfRequired();
return () => clearIntervalIfRequired();
}, [countDownTimer]);
const createIntervalIfRequired = () => {
if (intervalId.current !== undefined) {
return;
}
if (countDownTimer <= MIN_TIMER_VALUE) {
return;
}
const createdIntervalId = setInterval(() => {
setCountDownTimer((previousValue) => previousValue - 1);
}, 1000);
intervalId.current = createdIntervalId;
};
const clearIntervalIfRequired = () => {
if (countDownTimer > MIN_TIMER_VALUE || intervalId.current === undefined) {
return;
}
clearInterval(intervalId.current);
intervalId.current = undefined;
};
return [countDownTimer, setCountDownTimer];
}
Edit based on comment :
useCountdown
hook can be used in the CountDownTimer
component. also adding an isVisible function to the return value of useCountdown, to check if the timer is visible or not:
function useCountdown(initialSeconds, onCountdownEnd) {
const [countDownTimer, setCountDownTimer] = useState(initialSeconds);
const intervalId = useRef<NodeJS.Timer | undefined>(undefined);
useEffect(() => {
createIntervalIfRequired();
return () => clearIntervalIfRequired();
}, [countDownTimer]);
const createIntervalIfRequired = () => {
if (intervalId.current !== undefined) {
return;
}
if (countDownTimer <= MIN_TIMER_VALUE) {
return;
}
const createdIntervalId = setInterval(() => {
setCountDownTimer((previousValue) => {
if (previousValue - 1 <= MIN_TIMER_VALUE) {
onCountdownEnd(); // invoke the callback when timer reaches the minimum value
}
return previousValue - 1;
});
}, 1000);
intervalId.current = createdIntervalId;
};
const clearIntervalIfRequired = () => {
if (countDownTimer > MIN_TIMER_VALUE || intervalId.current === undefined) {
return;
}
clearInterval(intervalId.current);
intervalId.current = undefined;
};
const isVisible = () => {
return countDownTimer > MIN_TIMER_VALUE;
};
return {countDownTimer, setCountDownTimer, isVisible};
}
You can then use this hook in your CountDownTimer
component like this:
const CountDownTimer = ({ initialSeconds, onCountdownEnd }) => {
const { countDownTimer, setCountDownTimer, isVisible } = useCountdown(initialSeconds, onCountdownEnd);
useEffect(() => {
setCountDownTimer(initialSeconds);
}, [initialSeconds]);
return (
<span className={`px-2 ${!isVisible() ? "hidden" : ""}`}>
{beautifyTime(countDownTimer)}
</span>
);
};
Here, CountDownTimer
receives initialSeconds as a prop, and passes it to useCountdown. The useEffect hook in CountDownTimer
ensures that the timer is reset whenever initialSeconds changes. The isVisible
function is used to control the visibility of the timer.
The parent component would look like this:
const ParentComponent = () => {
const [initialSeconds, setInitialSeconds] = useState(60);
const [countDownVisible, setCountDownVisible] = useState(true); // Add a new state for the visibility
const handleCountdownEnd = () => {
// This function will be called when the countdown ends
setCountDownVisible(false); // Hide the countdown when it ends
};
return (
<div>
{/* Other code... */}
{countDownVisible && <CountDownTimer initialSeconds={initialSeconds} onCountdownEnd={handleCountdownEnd}/>}
</div>
);
};
In this case, ParentComponent
manages initialSeconds which controls the start value of the countdown. When it wants to start a countdown, it can just update initialSeconds, and CountDownTimer will start counting down from that value. Note that initialSeconds doesn't need to be state - it could also be a prop, or calculated based on other values. The important part is that it controls the start value of the countdown.
通过集体智慧和协作来改善编程学习和解决问题的方式。致力于成为全球开发者共同参与的知识库,让每个人都能够通过互相帮助和分享经验来进步。
评论