Andrew Zheng
Andrew Zheng

Reputation: 2848

type for useRef on a dynamic component

To avoid code duplication. We use some of the dynamic tag approaches. Basically, we have a component like this

type NavUtilDropdownProps = {
  ...
  component?: keyof JSX.IntrinsicElements;
  ...
  children: React.ReactElement;
};
const NavUtilDropdown: FC<NavUtilDropdownProps> = (props) => {
  const ref = useRef<HTMLElement | null>(null);
  const {
    ...
    component: Component = 'div',
  }
  ....
  {/* the Component will be whatever string we pass in,such as div section or a */}
  return <Component ref={ref} />
}

However, I'm getting a couple of TypeScript errors.

First. TS2590: Expression produces a union type that is too complex to represent. I believe this is cause by the second error, which provides more information.

The second error is here.

Error:(163, 6) TS2322: Type '{ children: Element[]; ref: MutableRefObject<HTMLElement | null>; className: string; "data-testid": string; onMouseLeave: (event: MouseEvent<Element, MouseEvent>) => void; }' is not assignable to type 'SVGProps<SVGSymbolElement>'.
  Types of property 'ref' are incompatible.
    Type 'MutableRefObject<HTMLElement | null>' is not assignable to type 'LegacyRef<SVGSymbolElement> | undefined'.
      Type 'MutableRefObject<HTMLElement | null>' is not assignable to type 'RefObject<SVGSymbolElement>'.
        Types of property 'current' are incompatible.
          Type 'HTMLElement | null' is not assignable to type 'SVGSymbolElement | null'.
            Type 'HTMLElement' is missing the following properties from type 'SVGSymbolElement': ownerSVGElement, viewportElement, correspondingElement, correspondingUseElement, and 2 more.

Minimal example:

import React, { FC, useRef } from 'react'

type NavUtilDropdownProps = {
  component?: keyof JSX.IntrinsicElements;
};

const NavUtilDropdown: FC<NavUtilDropdownProps> = ({component: Component}) => {
  const ref = useRef<HTMLElement | SVGSymbolElement | null>(null);
  if (!Component) return null
  
  return <Component ref={ref} /> // type errors
}

See playground

Upvotes: 3

Views: 1534

Answers (4)

jsejcksn
jsejcksn

Reputation: 33691

This involves a few parts, and it's not simple, but I'll try to break it down.

Note that this answer infers that you want to return a not-deprecated HTML React element, because:

  1. In your original question details, the generic provided to useRef was HTMLElement | null

  2. using deprecated APIs in new/refactored code is an anti-pattern

  3. assigning a ref to a standard component via props is not supported — for that you'd need forwardRef)

Let's start with the props:

type NavUtilDropdownProps = {
  component?: keyof JSX.IntrinsicElements;
};

The type keyof JSX.IntrinsicElements represents a union of many strings including deprecated element tag names, SVG element tag names, etc.

To get a union of strings representing not-deprecated HTML element tag names, you can use keyof HTMLElementTagNameMap.

To get a more exhaustive list (including SVG tag names), you can use something like this:

TS Playground

import { type ReactHTML, type ReactSVG } from "react";

type AnyHtmlOrSvgTagNameNotDeprecated = Exclude<
  keyof ReactHTML | keyof ReactSVG,
  keyof HTMLElementDeprecatedTagNameMap
>;

Note that there are also MathML tags, but I'm not sure about React's support of those.

With that out of the way, let's move on to the described problem:

In the return statement return <Component ref={ref} /> (playground), the JSX markup is inferred by the compiler to be a union of all types of React elements associated to the string literals in the union keyof JSX.IntrinsicElements. And because of that, the ref attribute is inferred to be a mapped type representing a union of all of those elements' associated ref types. This mapped union is too complex for the compiler to represent (it's a current limitation of TypeScript's control flow analysis). That's the reason for the first error. The reason for the second error is that no specific element type can satisfy the union of all possible element types.

So what can be done about it? There are several solutions — let's explore:

As a simple solution, you can ignore the compiler diagnostic error by using a // @ts-expect-error comment directive on the preceding line:

TS Playground

import { type ReactElement, useRef } from "react";

type NavUtilDropdownProps = {
  tagName?: keyof HTMLElementTagNameMap;
};

const NavUtilDropdown = (
  { tagName: TagName = "div" }: NavUtilDropdownProps,
): ReactElement => {
  const ref = useRef<HTMLElement>(null);
  // @ts-expect-error
  return <TagName ref={ref} />;
};

Suppressing compiler diagnostic errors is generally a bad idea, but there are times when you (the programmer) have more knowledge than the compiler, so tools like this directive (and type assertions, etc.) exist for these cases.

If you prefer never to use such directives under any circumstance, then you can assign your more narrow-typed variables to wider-annotated, but compatible types that the compiler will accept:

TS Playground

import { type ClassAttributes, type ReactElement, useRef } from "react";

type NavUtilDropdownProps = {
  tagName?: keyof HTMLElementTagNameMap;
};

const NavUtilDropdown = (
  { tagName = "div" }: NavUtilDropdownProps,
): ReactElement => {
  const ref = useRef<HTMLElement>(null);
  const TagName: string = tagName;
  const attributes: ClassAttributes<HTMLElement> = { ref };
  return <TagName {...attributes} />;
};

Finally, if you don't like either of the previous approaches, you can skip the JSX markup inference problem and use createElement directly, and TypeScript will have no inference trouble:

TS Playground

import { createElement, type ReactElement, useRef } from "react";

type NavUtilDropdownProps = {
  tagName?: keyof HTMLElementTagNameMap;
};

const NavUtilDropdown = (
  { tagName = "div" }: NavUtilDropdownProps,
): ReactElement => {
  const ref = useRef<HTMLElement>(null);
  return createElement(tagName, { ref });
};

You can read about the differences between createElement and JSX transforms at these pages:


Aside: You might have noticed that I didn't use null in a union with HTMLElement for the generic type provided to useRef:

const ref = useRef<HTMLElement>(null);
    //^? const ref: RefObject<HTMLElement>

That's because it's an overloaded function, and the return type when calling it with null as the initial value is RefObject<T> — which already includes null in the union for the type of the current property:

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

Upvotes: 2

Inspiraller
Inspiraller

Reputation: 3806

Try this:

import React, { FC, useRef } from 'react'

type PropHtmlType = HTMLElement | null;

type NavUtilDropdownProps = {
  component?: React.ComponentType<{ref: React.MutableRefObject<PropHtmlType> }>;
};

const NavUtilDropdown: FC<NavUtilDropdownProps> = ({component: Component}) => {
  const ref = useRef<PropHtmlType>(null);
  if (!Component) return null;
  
  return <Component ref={ref} />
}

Upvotes: 1

Alex Wayne
Alex Wayne

Reputation: 186984

I don't believe that you can use JSX if to create an HTML tag from a string tag name. And yes, keyof JSX.IntrinsicElements is a string, is just one of a few specific strings that are all the valid tag names.

But JSX is just some syntax sugar for React.createElement

<div foo="bar">baz</div>

Is equivalent to:

React.createElement('div', { foo: 'bar' }, 'baz')

React.createElement takes three arguments. First is the component (function or class) or the tag name as a string (one of keyof JSX.IntrinsicElements). Second is the props. And third is the children.

In your case that would look like this:

return React.createElement(Component, {ref}, null)

Working typescript playground

Upvotes: 0

basarat
basarat

Reputation: 276171

Based on your error, you need to add SVGSymbolElement to the union.

Fixed

type NavUtilDropdownProps = {
  ...
  component?: keyof JSX.IntrinsicElements;
  ...
  children: React.ReactElement;
};
const NavUtilDropdown: FC<NavUtilDropdownProps> = (props) => {
  const ref = useRef<HTMLElement | SVGSymbolElement | null>(null);
  const {
    ...
    component: Component = 'div',
  }
  ....
  {/* the Component will be whatever string we pass in,such as div section or a */}
  return <Component ref={ref} />
}

Upvotes: 0

Related Questions