Building External and Internal Next.js Routing Link Components For Material UI

Building External and Internal Next.js Routing Link Components For Material UI

Material-UI is an excellent UI library of React Components following the Material Design spec. Even if you don't adhere to the Material Design spec, Material-UI is super customizable for your specific needs.

A few of the great features of Material-UI include:

  • The ability for Buttons, etc to easily switch between using html anchor elements and html buttons for better semantic markup without having separate components. See component in Button API
  • The ability for Buttons, etc to use custom components to enable 3rd party routing, etc. See Composition with 3rd Party Library

The combination of the above features gets a bit murky when you are trying to leverage TypeScript. Since it took me a few hours and I couldn't find any definitive examples easily, I thought I'd share my approach.

Goals

  • Create an ExternalLink and InternalLink component that can be used with various Material-UI components such as Button.
  • The InternalLink component should wrap the Next.js Link component enabling routing.
  • Support all the normal HTML attributes for anchors including target, rel, title, etc as well as data attributes.
  • Implement onClick events for both components to allow for analytic event tracking, closing menus, etc.
  • Allow children of either component to be strings or other components
  • No TypeScript compiler errors or warnings in strict mode.

I started here thinking it would be "easy" to wrap a simple HTML anchor tag. However, it was a bit of a challenge to figure out the types. Here is the component I settled on.

import React from 'react';

function clickHandler(e: React.MouseEvent): void {
  // Add your custom event handler code here - record analytics, etc
  console.log('ExternalLink.clickHandler:', e);
}

interface ExternalLinkProps extends React.HTMLProps<HTMLAnchorElement> {
  href: string; // Forces href to be required - see "Gotchas"
  children: React.ReactNode; // Forces children to be required
}

// eslint-disable-next-line react/display-name
const ExternalLink = React.forwardRef<HTMLAnchorElement, ExternalLinkProps>((props, ref) => {
  // Peel off the onClick handler if given
  const { onClick, ...rest } = props;

  const wrappedOnClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void => {
    if (onClick) {
      // If the consumer passed in onClick, call it
      onClick(e);
    }

    // Finally, call our External Handler
    clickHandler(e);
  };

  return <a ref={ref} {...rest} onClick={wrappedOnClick} />;
});

export default ExternalLink;

The key points here:

  • Material-UI requires a component that can accept a ref. As such, we have to wrap our component in a React.forwardRef and pass it to the anchor.
  • Our type definition ExternalLinkProps extends React.HTMLProps<HTMLAnchorElement> which allows for all the HTML anchor attributes. However, I am enforcing the href and children to be required. See Gotchas below for more details.
  • We intercept the onClick so we can wrap it if given so we can extend click handler functionality.
  • All other props are forwarded down to the HTML anchor

The key difference with this component is that I needed to support Next routing. The Next Link component takes in a series of props and binds to its children to enable routing. As such, it has to wrap something.

import React, { ReactNode, useContext } from 'react';
import NextLink from 'next/link';

interface InternalLinkProps extends React.HTMLProps<HTMLAnchorElement> {
  href: string; // Forces href to be required - see "Gotchas"
  as?: string; // Optional Next.js property to support dynamic routing
  children: ReactNode; // 
}

// eslint-disable-next-line react/display-name
const InternalLink = React.forwardRef<HTMLAnchorElement, InternalLinkProps>((props, ref) => {
  // Peel off the onClick handler if given and the next props...
  const { href, as, onClick, ...rest } = props;

  // I defined the handler in the component here because I need access to react context ala useContext ... not demonstrated here
  const wrappedOnClick = (e: React.MouseEvent<HTMLAnchorElement, MouseEvent>): void => {
    if (onClick) {
      // If the consumer passed in onClick, call it
      onClick(e);
    }

    // Record analytics, close menus, etc
  };

  return (
    <NextLink {...{ href, as }}>
      <a ref={ref} {...rest} onClick={wrappedOnClick} />
    </NextLink>
  );
});

export default InternalLink;

The key points here:

  • Very similar signature to ExternalLink above with the same features.
  • Wraps the Next Link component thus enabling routing

Examples

 // TS error - no href
<ExternalLink>Lorem</ExternalLink>
<InternalLink>Lorem</InternalLink>

// TS error - no children
<ExternalLink href="https://www.hashnode.com" />
InternalLink href="https://www.hashnode.com" />

// Success: Text Children
<ExternalLink href="https://www.hashnode.com">Lorem ipsum</ExternalLink> 
<InternalLink href="/contact">Lorem ipsum</InternalLink>

 // Success - other components
<ExternalLink href="https://www.hashnode.com"><span>Lorem ipsum</span</ExternalLink>
<InternalLink href="/contact"><span>Lorem ipsum</span></InternalLink>

 // Success - other React components
 <ExternalLink href="https://www.hashnode.com"><MailIcon ... /></ExternalLink>
<InternalLink href="/contact" as="/contact"><MailIcon /></InternalLink>

 // Success - custom onClick gets wrapped correctly
 <ExternalLink href="https://www.hashnode.com" onClick={(): void => { console.log('inline onClick called'); }}>Lorem</ExternalLink>
<InternalLink href="/contact" onClick={(): void => { console.log('inline onClick called'); }}>Lorem</InternalLink>

// Success - junk drawer of html attributes
 <ExternalLink href="https://www.hashnode.com" title="Lorem" target="_blank" rel="noopener" id="mylink">Lorem ipsum</ExternalLink>
<InternalLink href="/contact" title="Lorem" target="_blank" rel="noopener" id="mylink2">Lorem ipsum</InternalLink>

// Success Using with Material-Ui button
<Button variant="outlined" color="primary" id="b1" href="https://hashnode.com" target="_blank" component={ExternalLink}>
  External Link
</Button>
<Button variant="outlined" color="secondary" id="b2" href="/contact" component={InternalLink}>
  Internal Link
</Button>

Here is a screenshot of the final two examples: Screen Shot 2020-01-24 at 5.02.35 PM.png

... and the rendered markup. Screen Shot 2020-01-24 at 5.03.23 PM.png

It feels like we have a fairly complete pair of interchangeable internal and external link components that we can easily pass either to Material-UI components and achieved our other goals. Success!

Gotchas

I'm learning TypeScript so these things were a source of confusion:

React.HTMLAttributes vs. React.HTMLProps

When defining the props interface, extend React.HTMLProps<HTMLAnchorElement> rather than React.HTMLAttributes<HTMLAnchorElement>. The later doesn't allow the href attribute. However, the former makes the href optional (presumably for internal page anchors) and so I added it to my type definition anyway, making the whole gotcha moot.

Event Types

React wraps the native browser events in Synthentic Event. As such, the type definition isn't the normal function definition type. React further defines these types such that our onClick has a type of MouseEvent. This got even weirder as I was passing around the setState function from React's useState hook which has a type of React.Dispatch<React.SetStateAction> but I'll discuss that further in an different article.

While Next 9 is written in TypeScript, I had a heck of a time attempting to extend the LinkProps. Specifically, href and as are Urltypes and conflict with the React HTML types. I gave up due to time, but I'd like to revisit this. The goal would be that I could simply do:

type InternalLinkProps extends React.HTMLProps<HTMLAnchorElement> & NextLinkProps

Forward Ref and Component Name issue

When using forwardRef, I was getting ESlint warnings about not having a component name for the wrapped component. I dug into it a bit, but the proper solution seemed fairly verbose for this component and I chose to suppress the warnings with // eslint-disable-next-line react/display-name

Conclusion

This is my first time converting existing components utilizing Material-UI to TypeScript. If you have a better solution or find a typo, please point it out in the comments. Additionally, check out other programming articles at blainegarrett.com or follow me on twitter @blainegarrett.

Image Credit: IMG_20170829_165725 by leaf watoru is licensed under CC BY-SA 2.0