What's the difference between React's ForwardedRef and RefObject?
Asked Answered
I

1

17

In a React codebase I'm working on I have a custom hook which accepts a RefObject as a parameter, and an accompanying Provider to be used with such hook:

export const ScrollUtilsProvider = React.forwardRef<HTMLDivElement, ScrollUtilsProviderProps>(
  (props, ref) => {
    const scrollUtils = useScrollUtils(ref) // issue happens on this line
    return <div ref={ref}><ScrollUtilsContext.Provider value={scrollUtils}>{props.children}</ScrollUtilsContext.Provider></div>
  },
)

export const useScrollUtils = <T extends Element>(ref: RefObject<T>) => {
  return {
    // some cool functions w/ the passed ref
  }
}

The error message I receive:

Argument of type 'ForwardedRef<HTMLDivElement>' is not assignable to parameter of type 'RefObject<HTMLDivElement>'.
  Type 'null' is not assignable to type 'RefObject<HTMLDivElement>'.

Digging into both types I realised they are really different:

// from @types/[email protected]
interface RefObject<T> {
  readonly current: T | null;
}
type ForwardedRef<T> = ((instance: T | null) => void) | MutableRefObject<T | null> | null;

My questions are:

  • why are these types so different?
  • is there a way to use a ForwardedRef as a RefObject?
  • if not, is there anything that makes ForwardedRefs so special?
Inigo answered 17/7, 2022 at 22:10 Comment(0)
W
32

RefObject

interface RefObject<T> {
    readonly current: T | null;
}

RefObject is the return type of the React.createRef method.

When this method is called, it returns an object with its only field .current set to null. Soon after, when a render passes the ref to a component, React will set .current to a reference to the component. This component will generally be a DOM element (if passed to a HTML element) or an instance of a class component (if passed to a custom class component).

Note that RefObject is very similar to MutableRefObject<T | null>, with the exception that .current is readonly. This type specification is only made to indicate that the .current property is managed internally by React and should not be modified by the code in a React app.

MutableRefObject

interface MutableRefObject<T> {
    current: T;
}

MutableRefObject is the return type of the React.useRef method. Internally, React.useRef makes a MutableRefObject, stores it to the state of the functional component, and returns the object.

Note that when objects are stored to the state of a React component, modifying their properties will not trigger a re-render (since Javascript objects are reference types). This situation allows you to mimic class instance variables in functional components, which don't have instances. In other words, you can think of React.useRef as a way to associate a variable with a functional component without it affecting the component's renders.

Here's an example of a class component using instance variables and a functional component using React.useRef to achieve the same purpose:

class ClassTimer extends React.Component {
    interval: NodeJS.Timer | null = null;

    componentDidMount() {
        this.interval = setInterval(() => { /* ... */ });
    }

    componentWillUnmount() {
        if (!this.interval) return;
        clearInterval(this.interval);
    }

    /* ... */
}

function FunctionalTimer() {
    const intervalRef = React.useRef<NodeJS.Timer>(null);

    React.useEffect(() => {
        intervalRef.current = setInterval(() => { /* ... */ });
        return () => {
            if (!intervalRef.current) return;
            clearInterval(intervalRef.current);
        };
    }, []);

    /* ... */
}

ForwardedRef

type ForwardedRef<T> = 
    | ((instance: T | null) => void)
    | MutableRefObject<T | null> 
    | null;

ForwardedRef is the type of ref React passes to functional components using React.forwardRef.

The main idea here is that parent components can pass a ref down to child components. For example, MyForm can forward a ref to MyTextInput, allowing the former to access the .value of the HTMLInputElement that the latter renders.

Breaking down the union type:

  • MutableRefObject<T | null> - The forwarded ref was created with React.useRef.

  • ((instance: T | null) => void) - The forwarded ref is a callback ref.

  • null - No ref was forwarded.

Using ForwardedRef in the child component

When a child component receives a ForwardedRef, it is often to expose the ref to a parent. However, sometimes the child component may need to use the ref itself. In this case, you can use a hook to the reconcile each of the ForwardedRef types listed above.

Here is a hook from this article (adjusted for Typescript) that helps achieve this:

function useForwardedRef<T>(ref: React.ForwardedRef<T>) {
    const innerRef = React.useRef<T>(null);

    React.useEffect(() => {
        if (!ref) return;
        if (typeof ref === 'function') {
            ref(innerRef.current);
        } else {
            ref.current = innerRef.current;
        }
    });

    return innerRef;
}

The idea behind this hook is that the component can create its own ref, which it can use regardless of whether the parent forwarded a ref. The hook helps ensure that any forwarded ref's .current property is kept in sync with the inner one's.

The return type of this hook is MutableRefObject<T>, which should be compatible with the RefObject<T> argument in your code snippet for useScrollUtils, e.g.:

const MyComponent = React.forwardRef<HTMLDivElement>(
    function MyComponent(_props, ref) {
        const innerRef = useForwardedRef(ref);

        useScrollUtils(innerRef);

        return <div ref={innerRef}></div>;
    }
);
Walke answered 20/7, 2022 at 5:55 Comment(4)
This is a Pro move ;) ThanksHydrolyte
@Walke about the useForwardedRef(), it sounds me strange the assignment inside else statement: ref.current = innerRef.current. You sure it's not the other way around innerRef.current = ref.current; since you're returning innerRef?Ninetieth
@Ninetieth Refs allow information to flow upward from child to parent. Check out callback refs and consider whether ref(innerRef.current) would be consistent with innerRef.current = ref.current.Walke
@rpatel, I am running into a similar issue where the component defined with forwardRef does not care about the ref. In other words, if the parent passes a ref, it should forward it, otherwise it doesn't care. However, I am getting a TypeScript error in my solution when I don't pass a ref. Would you mind helping. Here's the StackOverflow question.Southwards

© 2022 - 2024 — McMap. All rights reserved.