Бывают случаи, когда нам необходимо в обязательном порядке изменять элементы 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>
);
};