By Bugu
Speaking of useRef
, you are surely familiar with it: you can use it to obtain DOM elements, and to maintain a constant reference across multiple renderings...
However, are you really using useRef
correctly? Can your implementation avoid all the pitfalls in the following scenarios when used together with TypeScript and when used to write component libraries?
Which of the following implementations is correct?
function MyComponent() {
// Implementation 1
const ref = useRef();
// Implementation 2
const ref = useRef(undefined);
// Implementation 3
const ref = useRef(null);
// Calculate the size of the DOM element via ref
// 🚨 This code intentionally leaves a pitfall. Where is it? Please see below.
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
}, [ref.current]);
return <div ref={ref} />;
}
If you only look at JS, there seems to be no difference between the implementations, but if you turn on the type prompt of TS, you can find the clue:
function MyComponent() {
// ❌ Implementation 1
// You will get a MutableRefObject<HTMLDivElement | undefined>,
// that is, the ref.current type is HTMLDivElement | undefined,
// and this causes you to check whether the DOM element is undefined every time you obtain it, which is troublesome.
const ref = useRef<HTMLDivElement>();
// ❌ Implementation 2.1
// You may want to get a MutableRefObject<HTMLDivElement>, but the initial value passed in
// undefined is not an HTMLDivElement. Therefore, TS reports an error.
const ref = useRef<HTMLDivElement>(undefined);
// ❌ Implementation 2.2
// Equivalent to Implementation 1, but requires more typing.
const ref = useRef<HTMLDivElement | undefined>(undefined);
// ✅ Implementation 3
// You will get a RefObject<HTMLDivElement>, where
// the ref.current type is HTMLDivElement | null.
// The current of the ref cannot be modified from the outside, which is more consistent with the semantics of the usage scenario,
// and is also the recommended way by React to obtain DOM elements.
// Note: If the strictNullCheck is not enabled in your tsconfig, this definition will not apply,
// so make sure to enable the strictNullCheck.
const ref = useRef<HTMLDivElement>(null);
// Calculate the size of the DOM element via ref
// 🚨 This code intentionally leaves a pitfall. Where is it? Please see below.
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
}, [ref.current]);
return <div ref={ref} />;
}
Ref can also pass in a function, which receives the ref object as a parameter, so we can obtain the DOM element this way as well:
function MyComponent() {
const [divEl, setDivEl] = useState<HTMLDivElement | null>(null);
// Calculate the size of the DOM element
useEffect(() => {
if (divEl) {
divEl.current.getBoundingClientRect();
}
}, [divEl]);
return <div ref={setDivEl} />;
}
In scenario 1, we left a pitfall. Can you spot what is wrong with the following code?
/* 🚨 Incorrect example, do not copy */
function MyComponent({ visible }: { visible: boolean }) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
// ...
}, [ref.current]);
return <>{visible && <div ref={ref}/>}</>;
}
This piece of code has two issues:
useLayoutEffect
According to the analysis in Scenario 1:
useRef<HTMLDivElement>(null)
returns a type of RefObject<HTMLDivElement>
, where the ref.current
type is HTMLDivElement | null
. Therefore, from the perspective of TS types alone, we should judge if ref.current
is null.
You might think that since I am inside useLayoutEffect
, and the component DOM has been created at this point, ref.current
should exist, and thus judgment for null is unnecessary. (or using !
to force it to be non-null)
In the above usage scenario, it is indeed possible to do so. However, if the div
is conditionally rendered, there is no guarantee that the component will have been rendered by the time useLayoutEffect
runs, naturally meaning ref.current
may not exist.
useLayoutEffect deps
This issue relates more fundamentally to the intended use of useLayoutEffect
.
The execution timing of useLayoutEffect
is:
renders
are completed).createElement
are completed).Since its execution timing is before the repaint, the user will not see a "flash" when making changes to the generated DOM. For example, you could calculate the size of an element and, if it is too large, modify the CSS to make it wrap automatically, thereby achieving overflow detection.
Another common scenario is obtaining native components in useLayoutEffect
to add native listeners, get underlying HTMLMediaElement
instances to control playback, or add observers like ResizeObserver
and IntersectionObserver
.
Here, since the div
is conditionally rendered, we would obviously want the operations in useLayoutEffect
to run after each rendering. Therefore, we might want to include ref.current
in the dependencies
of useLayoutEffect
, but this is entirely incorrect.
Let us walk through the rendering process of MyComponent
:
visible
change triggers a render.useRef
executes, and ref.current
retains its previous value.useLayoutEffect
executes and checks the dependencies, finding no changes, so it skips the execution.div
.<div ref={ref}>
, React uses the new DOM element to update ref.current
.Clearly, useLayoutEffect
is not triggered again here, and any changes to ref.current
will only be detected during the next rendering. This deviates from our expectation that useLayoutEffect
ensures users do not see a "flash".
The solution is to use the same conditions as those for conditional rendering as the deps of useLayoutEffect
:
function MyComponent({ visible }: { visible: boolean }) {
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
// There is no need to check if (visible), because if there is ref.current here, then it must be visible.
if (ref.current) {
const rect = ref.current.getBoundingClientRect();
}
}, [/* ✅ */ visible]);
// In this way, when visible changes, the useLayoutEffect will be triggered in the same rendering.
return <>{visible && <div ref={ref}/>}</>;
}
// Alternatively, you can extract the <div> to be a separate component to avoid the above issue.
Finally, if the operation on the DOM element is not required before repaint, a more recommended implementation is to use the functional form:
function MyComponent({ visible }: { visible: boolean }) {
// ✅ No need to use ref
const [video, setVideo] = useState<Video | null>(null);
const play = useCallback(() => video?.play(), [video]);
// ✅ Use a regular useEffect
useEffect(() => {
console.log(video.currentTime);
}, [video]);
return <>{visible && <video ref={setVideo}/>}</>;
}
You have implemented a component and want to pass an incoming ref to the root element rendered within your component. Sounds simple, right?
Moreover, for some reason, your component also needs to use the ref of the root component. So you write the following code:
/* 🚨 Incorrect example, do not copy */
const MyComponent = forwardRef(
function (
props: MyComponentProps,
// type ForwardedRef<T> =
// | ((instance: T | null) => void)
// | MutableRefObject<T | null>
// | null
// ✅ This tool type covers the case of passing useRef and setState and is the correct implementation.
ref: ForwardedRef<HTMLDivElement>
) {
useLayoutEffect(() => {
const rect = ref.current.getBoundingClientRect();
// Use rect for calculations
}, []);
return <div ref={ref}>{/* ... */}</div>;
}
});
Wait, what if the caller does not pass a ref
? Thinking about this, you modify the code to:
/* 🚨 Incorrect example, do not copy */
const MyComponent = forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const localRef = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const rect = localRef.current.getBoundingClientRect();
// Use rect for calculations
}, []);
return <div ref={(el: HTMLDivElement) => {
localRef.current = el;
if (ref) {
ref.current = el;
}
}}>{/* ... */}</div>;
}
});
This code will clearly cause TS to report an error because ref
might be a function, and all you need to do is pass it directly to <div>
. Thus, you end up writing a bunch of code to handle various scenarios...
A better solution is to use react-merge-refs
:
import { mergeRefs } from "react-merge-refs";
const MyComponent = forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<HTMLDivElement>
) {
const localRef = React.useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
const rect = localRef.current.getBoundingClientRect();
// Use rect for calculations
}, []);
return <div ref={mergeRefs([localRef, ref])} />;
}
);
Complex components like Form and Table often maintain a lot of internal state, making them unsuitable for controlled operations. When callers need to control component behavior, they often adopt this pattern:
function MyPage() {
const ref = useRef<FormRef>(null);
return (
<div>
<Button onClick={() => { ref.current.reset(); }}> Reset Form </Button>
<Form actionRef={ref}>{/* ... */}</Form>
</div>
);
}
This usage originates from the class component era when people used ref to access class instances and controlled components by calling instance methods.
Now, your super complex component also wants to interact with the caller in this manner, so you write the following implementation:
/* 🚨 Incorrect example, do not copy */
interface MySuperDuperComponentAction {
reset(): void;
}
const MySuperDuperComponent = forwardRef(
function (
props: MySuperDuperComponentProps,
ref: ForwardedRef<MySuperDuperComponentAction>
) {
const action = useMemo((): MySuperDuperComponentAction => ({
reset() {
// ...
}
}), [/* ... */]);
if (ref) {
ref.current = action;
}
return <div/>;
}
);
However, TS will not allow such code to pass type checks because the caller can pass a function as ref to receive the action, similar to how DOM elements are obtained.
The correct approach is to use the tool function useImperativeHandle
provided by React:
const MyComponent = forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<MyComponentRefType>
) {
// The useImperativeHandle function automatically handles both function ref and object ref,
// and the latter two parameters are essentially equivalent to useMemo.
useImperativeHandle(ref, () => ({
refresh: () => {
// ...
},
// ...
}), [/* deps */]);
// Imperative + Downward
// If your component also internally uses this imperative object, the recommended implementation is:
const actions = useMemo(() => ({
refresh: () => {
// ...
},
}), [/* deps */]);
useImperativeHandle(ref, () => actions, [actions]);
return <div/>;
}
);
If the internal component type is correct, forwardRef
will automatically detect the ref type:
const MyComponent = forwardRef(
function (
props: MyComponentProps,
ref: ForwardedRef<MyComponentRefType>
) {
return <div/>;
}
});
// The result type is:
// const MyComponent: ForwardRefExoticComponent<
// PropsWithoutRef<MyComponentProps> & RefAttributes<MyComponentRefType>
// >
// The final exported PropsWithoutRef<P> & RefAttributes<T> is the type that users can ultimately pass,
// where PropsWithoutRef ignores the ref of props in your component.
There is a question here: Do the Props exported by your component need to include ref? Since forwardRef
will forcibly alter your ref, there are two approaches:
MyComponentProps
, with the type MyComponentRefType
, and export it as the final Props.ComponentProps<typeof MyComponent>
to retrieve the final Props.However, when the component needs to pass the ref through layers, if you include ref in the Props, each layer of the component must use forwardRef, otherwise issues will arise:
/* 🚨 Incorrect example, do not copy */
interface OtherComponentProps {
ref?: Ref<OtherComponentActions>;
}
interface MyComponentProps extends OtherComponentProps {
myAdditionalProp: string;
}
// This is incorrect. No ref can be accessed in props!
function MyComponent({ myAdditionalProp, ...props }: MyComponentProps) {
console.log(myAdditionalProp);
return <OtherComponent {...props} />;
}
Therefore, a better solution is not to use the name ref
, for example, naming it actionRef
, which allows it to be included in the props and exported without any issues.
PropsWithoutRef<Props>
: Remove ref from Props, which can be used in scenarios such as HOC.PropsWithRef<Props>
: Do not add ref but ensure that ref does not contain a string. This is because in the past, it was possible to pass a string to ref instead of a function, but in modern practices, we generally do not do this.forwardRef: PropsWithoutRef<Props> & RefAttribute<RefType>
.RefAttribute<RefType>: { ref?: Ref<T> | undefined; }
.ForwardedRef<RefType>
: The only specified type for handling external refs within a component.MutableRefObject<RefType>
: The result of useRef
and createRef
.RefObject<RefType>
: The result of useRef(null)
.Ref<RefType>
: The type of the ref parameter, where RefType
is the type obtained by ref.current
, automatically including null.
RefObject
and functions.ForwardedRef
is that it accepts RefObject
rather than MutableRefObject
, thus it can take the result of useRef(null)
and be used in props. Within the component, since modification of ref.current
is needed, MutableRefObject
must be used.ForwardRefExoticComponent
: The return type of forwardRef
.These types are akin to the type interfaces provided by React. To ensure your components are compatible with as many versions of React as possible, please use the most appropriate types.
Disclaimer: The views expressed herein are for reference only and don't necessarily represent the official views of Alibaba Cloud.
Mock Framework Evolution under JDK11: From PowerMockito to Mockito Only
1,080 posts | 265 followers
FollowAlibaba Cloud Community - February 23, 2024
Alibaba Clouder - October 26, 2018
Alibaba Clouder - January 20, 2017
Alibaba F(x) Team - June 20, 2022
XianYu Tech - September 4, 2020
Hironobu Ohara - June 26, 2023
1,080 posts | 265 followers
FollowExplore Web Hosting solutions that can power your personal website or empower your online business.
Learn MoreExplore how our Web Hosting solutions help small and medium sized companies power their websites and online businesses.
Learn MoreBuild superapps and corresponding ecosystems on a full-stack platform
Learn MoreWeb App Service allows you to deploy, scale, adjust, and monitor applications in an easy, efficient, secure, and flexible manner.
Learn MoreMore Posts by Alibaba Cloud Community