TypeScript: Typing React useRef Hook

Updated on · 5 min read
TypeScript: Typing React useRef Hook

As TypeScript continues to gain popularity among the JavaScript community, it has become essential to understand how to effectively use its powerful typing system in combination with the widely-adopted React library.

One of the essential hooks in React is useRef. It has many uses, e.g. it allows preserving a value between renders without triggering a re-render, which makes it useful when working with DOM elements or third-party libraries. While useRef is straightforward to use, typing it correctly can be a challenge, especially when working with complex types.

In this post we will explore the intricacies of working with the useRef hook and TypeScript when it comes to scenarios where we need to imperatively modify DOM elements in React components. This is often the case when managing elements' focus or integrating third-party libraries into your React applications.

We'll walk through a practical example of controlling the focus state of an input element and demonstrate how to effectively type the useRef hook in TypeScript for this use case. Additionally, we'll look at some common pitfalls and discuss the differences between typing with a read-only property and a mutable one.

Focusing an input with useRef

Let's consider a simple use case where we want to manually set the focus on an input element when a button is clicked. The JavaScript code for this component would look like this:

jsx
import React, { useRef } from "react"; 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> ); };
jsx
import React, { useRef } from "react"; 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> ); };

TypeScript types for immutable useRef

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. The error we get after converting the file to TypeScript 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:

tsx
import React, { useRef } from "react"; export const CustomInput = () => { const inputRef = useRef(null); const onButtonClick = () => { if (inputRef.current !== null) { 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> ); };
tsx
import React, { useRef } from "react"; export const CustomInput = () => { const inputRef = useRef(null); const onButtonClick = () => { if (inputRef.current !== null) { 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> ); };

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

typescript
inputRef.current?.focus();
typescript
inputRef.current?.focus();

If inputRef.current is null or undefined, the expression short-circuits, and the 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 strange at first as we do assign the ref to the input element later. The issue is that TypeScript 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 element is expected via a generic type parameter when creating the ref.

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

This solves the "Property does not exist on type 'never'" error, and we don't get any more type complaints from TypeScript. The final code looks as follows:

tsx
import React, { useRef } from "react"; 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"} /> <button type={"button"} onClick={onButtonClick}> Focus input </button> </div> ); };
tsx
import React, { useRef } from "react"; 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"} /> <button type={"button"} onClick={onButtonClick}> Focus input </button> </div> ); };

Mutable vs immutable ref

The current typing of inputRef works fine for the cases where it's read-only, and we do not need to reassign its value (immutable ref). However, let's consider a scenario in which we need to manually add an event listener to an input, which can be particularly useful when working with third-party libraries. The code would look something like this:

tsx
import React, { useEffect, useRef } from "react"; 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> ); };
tsx
import React, { useEffect, useRef } from "react"; 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, as TypeScript is unable to infer the correct element type in this case and defaults to the more generic HTMLElement. However, we are certain that the element, in this case, is an input element, so it is safe to cast it accordingly. While the code appears functional, we encounter a TypeScript error: "Cannot assign to 'current' because it is a read-only property". Upon inspecting the current property, we find that its type is defined as React.RefObject<HTMLInputElement>.current: any. Diving further into the type definition for React.RefObject, it is defined as:

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

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

typescript
function useRef<T>(initialValue: T): MutableRefObject<T>; function useRef<T>(initialValue: T | null): RefObject<T>;
typescript
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 read-only current property. To fix it, we need to make the type param a union type of HTMLInputElement and null:

typescript
const inputRef = useRef<HTMLInputElement | null>(null);
typescript
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:

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

Now the "Cannot assign to 'current' because it is a read-only property" error is fixed and the final version of the code is as follows:

tsx
import React, { useEffect, useRef } from "react"; 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> ); };
tsx
import React, { useEffect, useRef } from "react"; 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> ); };

If you are curious about using TypeScript to work with the form elements in React, you may find this post helpful: TypeScript: Typing Form Events In React.

Conclusion

In this post, we've explored the intricacies of working with the useRef hook and TypeScript when it comes to scenarios where we need to imperatively modify DOM elements in React components. We've covered a practical example of controlling the focus state of an input element and demonstrated how to effectively type the useRef hook in TypeScript for this use case. Moreover, we've discussed some common pitfalls and the differences between typing with a read-only property and a mutable one.

Hopefully now, by understanding the nuances of TypeScript typings and the useRef hook, you can approach more complex scenarios in your React applications with confidence. Remember that when using TypeScript, it's essential to provide accurate type information to fully leverage its powerful type-checking capabilities and improve the quality and maintainability of your code.

References and resources