TypeScript: Typing React useRef hook

typescript hooks react

Image credit: Remy_Loz on Unsplash

There are cases when we need to imperatively modify DOM elements in React components outside the usual component flow. The most common examples are managing elements' focus or using third-party libraries (especially the ones not written in React) inside React applications.

This post will demonstrate how to type useRef hook in TypeScript on example of controlling the focus state of an input element.

Let's say we have a simple use case where we want to manually focus input on a button click. The JS code for the component would look like this:

export const CustomInput = () => {
    const inputRef = useRef(null);

    const onButtonClick = () => {
        inputRef.current.focus();
    };

    return (
        <div>
            <label htmlFor={"name"}>Name</label>
            <input id={"name"} placeholder={"Enter your name"} ref={inputRef}/>
            <button type={"button"} onClick={onButtonClick}>
                Focus input
            </button>
        </div>
    );
};

When we click on the Focus input button, the name input field gets focused, so far so good. Now we'd like to use TypeScript for this component. As the first step we can simply change the file's extension from .js to .tsx. The error we get after converting the file to TS is Object is possibly null for the line inputRef.current.focus();. This makes sense since we did set null as the initial value for inputRef. To fix this error, we can check that the current property of inputRef is not null before calling focus on it:

if (inputRef.current !== null) {
    inputRef.current.focus();
}

This can be simplified with the optional chaining operator , ?:

inputRef.current?.focus();

If inputRef.current is nullish (null or undefined), the expression short-circuits and focus method isn't called (if we would assign the result of the call to a variable, it'd be set as undefined in this case).
This fixes the type error, however it creates a new one - Property 'focus' does not exist on type 'never'. This seems weird at first as we do assign the ref to the input element later. The issue is that TS infers from the default value that the inputRef can never be anything other than null and will type it accordingly. We do know however that the ref will later contain an input element, so to fix this issue we need to explicitly tell the compiler which type of the element is expected:

const inputRef = useRef<HTMLInputElement>(null);

This solves the issue, and we don't get any type errors. The final code looks as follows:

export const CustomInput = () => {
    const inputRef = useRef<HTMLInputElement>(null);

    const onButtonClick = () => {
        inputRef.current?.focus();
    };

    return (
        <div>
            <label htmlFor={"name"}>Name</label>
            <input id={"name"} placeholder={"Enter your name"} ref={inputRef}/>
            <button type={"button"} onClick={onButtonClick}>
                Focus input
            </button>
        </div>
    );
};

useRef<HTMLInputElement>(null) vs useRef<HTMLInputElement | null>(null)

The current typing of inputRef works fine for the cases where we do not need to reassign its value. Let's consider a situation where we want to manually add an event listener to an input (useful when working with 3rd party libraries). The code would look something like this:

export const CustomInput = () => {
    const inputRef = useRef<HTMLInputElement>(null);

    useEffect(() => {
        inputRef.current = document.getElementById("name") as HTMLInputElement;
        inputRef.current.addEventListener("keypress", onKeyPress);

        return () => {
            inputRef.current?.removeEventListener("keypress", onKeyPress);
        };
    }, []);

    const onKeyPress = () => { /* Handle input key press */ };

    return (
        <div>
            <label htmlFor={"name"}>Name</label>
            <input id={"name"} placeholder={"Enter your name"}/>
            <button type={"button"}>Focus input</button>
        </div>
    );
};

Note that we need to cast the result of document.getElementById to HTMLInputElement, since TS cannot infer correct element type in this case and defaults to a more generic HTMLElement. We do know however that the element in this case is an input element, so it's safe to cast it accordingly. While the code looks fine, we get a TS error - Cannot assign to 'current' because it is a read-only property. Upon inspecting the current property we see that its type is defined as React.RefObject<HTMLInputElement>.current:any. Going deeper into the type definition for React.RefObject, it is defined as:

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

So how can we make it mutable? Following the type definition for useRef, we see that it in fact has a few overloads, the most important of which are:

function useRef<T>(initialValue: T): MutableRefObject<T>;
function useRef<T>(initialValue: T | null): RefObject<T>;

When specifying null as the default parameter, but not including it in the type param, we match the second overload for the useRef, getting a ref object with a readonly current property. To fix it, we need to include null in the type param:

const inputRef = useRef<HTMLInputElement | null>(null);

This will match the MutableRefObject overload and fix the type issue. There's also a handy note in the type definition for the hook:

Usage note: if you need the result of useRef to be directly mutable, include | null 
in the type of the generic argument.

The final version of the code is as follows:

export const CustomInput = () => {
    const inputRef = useRef<HTMLInputElement | null>(null);

    useEffect(() => {
        inputRef.current = document.getElementById("name") as HTMLInputElement;
        inputRef.current.addEventListener("keypress", onKeyPress);

        return () => {
            inputRef.current?.removeEventListener("keypress", onKeyPress);
        };
    }, []);

    const onKeyPress = () => { /* Handle input key press */ };

    return (
        <div>
            <label htmlFor={"name"}>Name</label>
            <input id={"name"} placeholder={"Enter your name"}/>
            <button type={"button"}>Focus input</button>
        </div>
    );
};