Search code examples
javascriptreactjsreact-hookscreateelementuse-ref

Using ref with React.createElement


I've got a reusable heading component that allows me to pass a tag prop, creating any sort of heading (h1, h2, h3 etc). Here's that component:

heading.tsx

import React, { ReactNode } from 'react';

import s from './Heading.scss';

interface HeadingProps {
  tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
  children: ReactNode;
  className?: string;
}

export const Heading = ({ tag, children, className }: HeadingProps) => {
  const Tag = ({ ...props }: React.HTMLAttributes<HTMLHeadingElement>) =>
    React.createElement(tag, props, children);

  return <Tag className={s(s.heading, className)}>{children}</Tag>;
};

However, I'm coming across a use case where I'd like to be able to have a ref, using the useRef() hook, on the Tag, so that I can access the element and animate with GSAP. However, I can't figure out how do this using createElement.

I've tried to do it by adding a ref directly to the Tag component, and adding it to the Tag props like so:

import React, { ReactNode, useRef } from 'react';

import s from './Heading.scss';

interface HeadingProps {
  tag: 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6';
  children: ReactNode;
  className?: string;
}

export const Heading = ({ tag, children, className }: HeadingProps) => {
  const headingRef = useRef(null);
  const Tag = ({ ...props }: React.HTMLAttributes<HTMLHeadingElement>) =>
    React.createElement(tag, props, children, {ref: headingRef});

  return <Tag className={s(s.heading, className)} ref={headingRef}>{children}</Tag>;
};

I receive the error Property 'ref' does not exist on type 'IntrinsicAttributes & HTMLAttributes<HTMLHeadingElement>'.

What am I doing wrong, and how can I safely add a ref to the component?

Thanks.


Solution

  • Use object spread to add the ref to the props:

    const { useRef, useEffect } = React;
    
    const Heading = ({ tag, children, className }) => {
      const headingRef = useRef(null);
      const Tag = (props) => React.createElement(tag, {ref: headingRef, ...props }, children);
      
      useEffect(() => { console.log(headingRef); }, []); // demo - use the ref
    
      return <Tag>{children}</Tag>;
    };
    
    ReactDOM.render(
      <div>
        <Heading tag="h1">h1</Heading>
        <Heading tag="h2">h2</Heading>
        <Heading tag="h3">h3</Heading>
      </div>,
      root
    );
    .as-console-wrapper { max-height: 100% !important; top: 0; left: 50% !important; }
    <script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
    
    <div id="root"></div>

    However, creating a component inside another component would mean that component would be recreated on each render. You can avoid that by using useMemo(). However an easier option would be to render the tag itself as JSX:

    const { useRef, useEffect } = React;
    
    const Heading = ({ tag: Tag, children, className }) => {
      const headingRef = useRef(null);
      
      useEffect(() => { console.log(headingRef); }, []); // demo - use the ref
    
      return <Tag className={className} ref={headingRef}>{children}</Tag>;
    };
    
    ReactDOM.render(
      <div>
        <Heading tag="h1">h1</Heading>
        <Heading tag="h2">h2</Heading>
        <Heading tag="h3">h3</Heading>
      </div>,
      root
    );
    .as-console-wrapper { max-height: 100% !important; top: 0; left: 50% !important; }
    <script crossorigin src="https://unpkg.com/react@17/umd/react.development.js"></script>
    <script crossorigin src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
    
    <div id="root"></div>