7
votes

I'm trying to add custom logic on top of @material-ui Button component.To do so, I'm creating a component that is wrapping the original Button and it runs properly.

My issue is to have this properly typed, especially to get the props from the original component. I had it working by following https://material-ui.com/guides/typescript/#usage-of-component-prop until I added React.forwardRef().

Working version without forwardRef:

import { Button, ButtonProps } from '@material-ui/core'
import React from 'react'

type ExtraProps = {
  target?: string
  rel?: string
  href?: string
  color?: 'default' | 'primary' | 'secondary'
}

// see https://material-ui.com/guides/typescript/#usage-of-component-prop
type Props<C extends React.ElementType> = ButtonProps<C, { component?: C }> & ExtraProps

const CustomButton = <C extends React.ElementType>({
  children,
  target,
  rel,
  href,
  ...rest
}: Props<C>) => {
  const relValue = target === '_blank' ? 'noopener noreferrer' : rel

  const linkValues = href ? { href, target, rel: relValue } : undefined

  return (
    <Button {...linkValues} {...rest}>
      {children}
    </Button>
  )
}

Non-working version with React.forwardRef:

import { Button, ButtonProps } from '@material-ui/core'
import React from 'react'

type ExtraProps = {
  target?: string
  rel?: string
  href?: string
  color?: 'default' | 'primary' | 'secondary'
}

// see https://material-ui.com/guides/typescript/#usage-of-component-prop
export type Props<C extends React.ElementType> = ButtonProps<C, { component?: C }> & ExtraProps

const CustomButton = React.forwardRef<HTMLButtonElement, Props<React.ElementType>>(
  ({ children, target, rel, href, ...rest }, ref) => {
    const relValue = target === '_blank' ? 'noopener noreferrer' : rel

    const linkValues = href ? { href, target, rel: relValue } : undefined

    return (
      <Button {...linkValues} {...rest} ref={ref}>
        {children}
      </Button>
    )
  }
)

When I say "non-working", it's because the type of CustomButton is:

React.ForwardRefExoticComponent<Pick<Props<React.ElementType<any>>, string | number | symbol> & React.RefAttributes<HTMLButtonElement>>

instead of

<C extends React.ElementType<any>>({ children, target, rel, href, ...rest }: Props<C>) => JSX.Element

It means I can pass any props to my CustomButton and TS will not enforced the contract at all.

How should I fix the version with React.forwardRef() in order to have correct typing?

Codesandbox demo: https://codesandbox.io/s/boring-hertz-0hutt?file=/src/App.tsx

1

1 Answers

0
votes

forwardRef is not generic. Which is the root of all evil. I am also struggling to achieve type safety. For now I have this solution:

type As = 'button' | 'a' | 'span';

type Element<T extends As = 'button'> = T extends 'span'
    ? HTMLSpanElement
    : T extends 'a'
    ? HTMLAnchorElement
    : HTMLButtonElement;

interface CommonButtonProps<T extends As> {
    view?: View;
    size?: Size;
    disabled?: boolean;
    iconLeft?: ReactElement;
    iconRight?: ReactElement;
    className?: string;
    progress?: boolean;
    baseElement?: T;
}

export type ButtonProps<T extends As = 'button'> = CommonButtonProps<T> &
    (T extends 'a'
        ? JSX.IntrinsicElements['a']
        : T extends 'span'
        ? JSX.IntrinsicElements['span']
        : T extends undefined
        ? JSX.IntrinsicElements['button']
        : JSX.IntrinsicElements['button']);

const Button = (
    { children, baseElement, ...props }: PropsWithChildren<ButtonProps<As>>,
    ref: React.Ref<Element<As>>,
) => {
    return (
        <StyledButton {...props} as={baseElement} ref={ref}>
            {children}
        </StyledButton>
    );
};

function genericForwardRef<T extends ForwardRefRenderFunction<any, any>>(Component: T) {
    const ForwardedComponent = forwardRef(Component);

    return <U extends As = 'button'>(
        props: PropsWithChildren<PropsWithoutRef<ButtonProps<U>> & RefAttributes<Element<U>>>,
    ) => <ForwardedComponent {...props} />;
}

export default genericForwardRef(Button);

(N.b.: I am using styled-components)