×
Community Blog Correct Usage of React: Ref

Correct Usage of React: Ref

This article introduces the correct usage of React's useRef and how to avoid potential pitfalls when using it with TypeScript.

By Bugu

1

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?

Scenario 1: Obtain DOM Elements

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} />;
}

Scenario 2: DOM Elements and useLayoutEffect

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:

1. No Null Judgment in 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.

2. Incorrect Configuration in useLayoutEffect deps

This issue relates more fundamentally to the intended use of useLayoutEffect.

The execution timing of useLayoutEffect is:

  • After VDOM creation (all renders are completed).
  • After DOM creation (DOM operations like createElement are completed).
  • Before the submission of the final rendering (before returning synchronization tasks).

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:

  1. A visible change triggers a render.
  2. useRef executes, and ref.current retains its previous value.
  3. useLayoutEffect executes and checks the dependencies, finding no changes, so it skips the execution.
  4. The rendering output includes the div.
  5. Due to <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}/>}</>;
}

Scenario 3: Pass and Obtain Ref Simultaneously in a Component

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])} />;
  }
);

Scenario 4: Expose Imperative Operations in Components

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/>;
  }
);

Scenario 5: Declare Ref Types in Component TS Exports

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:

  1. Include ref in MyComponentProps, with the type MyComponentRefType, and export it as the final Props.
  2. Use 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.

Bonus: TS Types Related to Ref

  • 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.
  • To add ref, you can mimic the implementation in 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.

    • Note: This type includes RefObject and functions.
    • Note: The difference between this type and 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.

0 1 0
Share on

Alibaba Cloud Community

1,062 posts | 261 followers

You may also like

Comments