onestopjs

by Martin Koparanov

0%

Chain links

Using type predicates with React Refs

Published: Feb 11, 2023

TL;DR:

import { RefObject } from 'react';
const refNotEmpty = <T, _>(value: RefObject<T>): value is { current: T } => {
return value.current !== null && value.current !== undefined;
};

Usage:

const ref = useRef<HTMLDivElement>(null);
const handleClick = () => {
if (refNotEmpty(ref)) {
console.log(ref.current);
}
};
return (
<>
<div ref={ref1} />
<button onClick={handleClick}>Calculate total height</button>
</>
);

If you want to know more, keep reading!

Type Predicates

If you are not familiar, type predicates are used to narrow a variable's type from multiple possible types, and more importantly, calm TypeScript down.

A simple example, which I kind of ripped off from TypeScript's handbook:

interface Fish {
name: string;
age: number;
swim: Function;
}
interface Bird {
name: string;
age: number;
fly: Function;
}
const isFish = (pet: Fish | Bird): pet is Fish => {
return (pet as Fish).swim !== undefined;
};
const doAnimalAction = (pet: Fish | Bird) => {
console.log(pet.name, pet.age);
if (isFish(pet)) {
pet.swim();
} else {
pet.fly();
}
};

The isFish function does all the magic. It checks if the object contains a property, which can only be part of Fish.

The check is arbitrary, you can check for whatever you want if it ensures the correct type.

Why React Refs are special

The problem

If you have worked with React Refs, you know that the ref object has a current property, which is possibly null (understandable, but still annoying).

It has the following signature:

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

So what can we narrow down from here? We can't touch whatever is inside the RefObject type, so how can we narrow the single property?

We can't. If there was a DefinedRefObject exported from React, which had this signature:

// THIS DOES NOT EXIST
interface DefinedRefObject<T> {
readonly current: T;
}

Then we could have a type predicate like that:

import { RefObject, DefinedRefObject } from 'react';
// THIS DOES NOT WORK
const refNotEmpty = <T, _>(
value: RefObject<T> | DefinedRefObject<T>
): value is DefinedRefObject<T> => {
return value.current !== null && value.current !== undefined;
};

The solution

Instead of dreaming about such an export, we can just narrow the type to { current: T }:

import { RefObject } from 'react';
const refNotEmpty = <T, _>(value: RefObject<T>): value is { current: T } => {
return value.current !== null && value.current !== undefined;
};

It's a bit of a workaround. We are also unfortunately now detaching from React. We are asserting that it is just some object with the current property. The potential issue is that React can get updated to contain something else as part of the RefObject, and our code won't know about this.
It's a bit of a contrived scenario, which probably will never happen, so I am fine with that.

How is it better than if ref.current?

const ref = useRef<HTMLDivElement>(null);
const handleClick = () => {
if (ref.current) {
console.log(ref.current);
}
};

It's debatable if it is. It still requires the if. Maybe one could argue it is easier to read, despite not being any shorter.

But where it shines is in arrays!

Usage in arrays

Imagine you have an array of React Refs, and you want to work only with refs which are not null, so you don't have to worry about null checks all the time.
Of course, you can use filter.

The usual way doesn't work

// doesn't work
[ref1, ref2, ref3]
.filter((ref) => !!ref.current)
.map((ref) => ref.current.offsetHeight);

Obviously, we know that all elements after the filter will not be null. Unfortunately, TypeScript can't automatically infer it.

The new way

// works!
[ref1, ref2, ref3].filter(refNotEmpty).map((ref) => ref.current.offsetHeight);

Yay! TypeScript now knows that all elements after the filter will be of type { current: T } and not { current: T | null }

Full example

import { useRef, RefObject } from 'react';
// the important part
const refNotEmpty = <T, _>(value: RefObject<T>): value is { current: T } => {
return value.current !== null && value.current !== undefined;
};
const App = () => {
const ref1 = useRef<HTMLDivElement>(null);
const ref2 = useRef<HTMLDivElement>(null);
const ref3 = useRef<HTMLDivElement>(null);
const allDivs = [ref1, ref2, ref3];
const handleClick = () => {
const totalHeight = allDivs
.filter(refNotEmpty)
// notice there is no check if div is defined
// because we have filtered all empty refs
.reduce((total, div) => total + div.current.offsetHeight, 0);
};
return (
<>
<div ref={ref1} />
<div ref={ref2} />
<div ref={ref3} />
<button onClick={handleClick}>Calculate total height</button>
</>
);
};