TypeScript: Типизация хука React useRef

Бывают случаи, когда нам необходимо в обязательном порядке изменять элементы DOM в компонентах React вне обычного потока компонентов.
Наиболее распространенные примеры — управление фокусом элементов или использование сторонних библиотек (особенно тех, которые написаны не на React) внутри React-приложений.

В этой заметке будет показано, как использовать хук useRef в TypeScript на примере управления состоянием фокуса элемента ввода.

Допустим, у нас есть простой сценарий использования, в котором мы хотим вручную фокусировать ввод при нажатии на кнопку. JS-код для компонента будет выглядеть следующим образом:

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>
    );
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Когда мы нажимаем на кнопку Focus input, поле ввода name фокусируется, пока все хорошо. Теперь мы хотим использовать TypeScript для этого компонента. В качестве первого шага мы можем просто изменить расширение файла с .js на .tsx. Ошибка, которую мы получаем после преобразования файла в TS, — Object is possibly null для строки inputRef.current.focus();. Это имеет смысл, поскольку мы установили null в качестве начального значения для inputRef. Чтобы исправить эту ошибку, мы можем проверить, что свойство current у inputRef не равно null перед вызовом focus на нем:

if (inputRef.current !== null) {
    inputRef.current.focus();
}
Войти в полноэкранный режим Выход из полноэкранного режима

Это можно упростить с помощью дополнительного оператора цепочки, ?:

inputRef.current?.focus();
Войти в полноэкранный режим Выход из полноэкранного режима

Если inputRef.current является нулевым (null или undefined), выражение замыкается и метод focus не вызывается (если бы мы присвоили результат вызова переменной, то в этом случае она была бы установлена как undefined).

Это исправляет ошибку типа, но создает новую — Property 'focus' does not exist on type 'never'. Сначала это кажется странным, поскольку позже мы присвоим ссылку элементу ввода. Проблема в том, что TS считает, исходя из значения по умолчанию, что inputRef никогда не может быть ничем другим, кроме как null, и напечатает его соответствующим образом. Однако мы знаем, что ссылка впоследствии будет содержать элемент ввода, поэтому для решения этой проблемы нам нужно явно указать компилятору, какой тип элемента ожидается:

const inputRef = useRef<HTMLInputElement>(null);
Войти в полноэкранный режим Выйти из полноэкранного режима

Это решает проблему, и мы не получаем никаких ошибок типа. Окончательный код выглядит следующим образом:

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)

Текущая типизация inputRef отлично подходит для случаев, когда нам не нужно переназначать его значение. Рассмотрим ситуацию, когда мы хотим вручную добавить слушателя событий к входу (полезно при работе со сторонними библиотеками). Код будет выглядеть примерно так:

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>
    );
};
Войти в полноэкранный режим Выйти из полноэкранного режима

Обратите внимание, что нам нужно привести результат document.getElementById к HTMLInputElement, поскольку TS не может определить правильный тип элемента в этом случае и по умолчанию использует более общий HTMLElement. Однако мы знаем, что элемент в данном случае является элементом ввода, поэтому его можно привести к соответствующему типу. Хотя код выглядит нормально, мы получаем ошибку TS — Cannot assign to 'current' because it is a read-only property. Проверив свойство current, мы видим, что его тип определен как React.RefObject<HTMLInputElement>.current:any. Если углубиться в определение типа для React.RefObject, то он определяется как:

interface RefObject<T> {
    readonly current: T | null;
}
Войти в полноэкранный режим Выйти из полноэкранного режима

Как же мы можем сделать его изменяемым? Следуя определению типа для useRef, мы видим, что у него есть несколько перегрузок, наиболее важными из которых являются:

function useRef<T>(initialValue: T): MutableRefObject<T>;
function useRef<T>(initialValue: T | null): RefObject<T>;
Войти в полноэкранный режим Выход из полноэкранного режима

При указании null в качестве параметра по умолчанию, но не включая его в параметр type, мы получаем вторую перегрузку для useRef, получая объект ref со свойством readonly current. Чтобы исправить это, нам нужно включить null в параметр type:

const inputRef = useRef<HTMLInputElement | null>(null);
Войти в полноэкранный режим Выйти из полноэкранного режима

Это будет соответствовать перегрузке MutableRefObject и исправит проблему с типом. В определении типа также есть удобное примечание для хука:

Usage note: if you need the result of useRef to be directly mutable, include | null in the type of the generic argument.
Войти в полноэкранный режим Выйти из полноэкранного режима

Окончательный вариант кода выглядит следующим образом:

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>
    );
};
Вход в полноэкранный режим Выйти из полноэкранного режима

Оцените статью
devanswers.ru
Добавить комментарий